├── tests ├── __init__.py ├── test_device.py ├── test_priors.py ├── test_online_likelihoods.py └── test_integration.py ├── changepoint_detection_example.png ├── changepoint_detection_results.png ├── .idea └── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── requirements.txt ├── LICENSE ├── bayesian_changepoint_detection ├── __init__.py ├── hazard_functions.py ├── device.py ├── priors.py ├── online_likelihoods.py ├── generate_data.py ├── bayesian_models.py └── offline_likelihoods.py ├── .github └── workflows │ └── cd.yml ├── examples ├── example.py ├── simple_example.py ├── basic_usage.py ├── multivariate_example.py └── gpu_acceleration.py ├── .gitignore ├── test.py ├── setup.py ├── setup.cfg ├── pyproject.toml ├── quick_test.py ├── docs └── gpu_online_detection_guide.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test suite for bayesian_changepoint_detection package. 3 | """ -------------------------------------------------------------------------------- /changepoint_detection_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildensia/bayesian_changepoint_detection/HEAD/changepoint_detection_example.png -------------------------------------------------------------------------------- /changepoint_detection_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildensia/bayesian_changepoint_detection/HEAD/changepoint_detection_results.png -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core dependencies for bayesian-changepoint-detection 2 | torch>=2.0.0 3 | numpy>=1.21.0 4 | scipy>=1.7.0 5 | matplotlib>=3.5.0 6 | seaborn>=0.11.0 7 | 8 | # Optional dependencies (uncomment as needed) 9 | # Development dependencies 10 | # pytest>=7.0.0 11 | # pytest-cov>=4.0.0 12 | # black>=23.0.0 13 | # isort>=5.12.0 14 | # flake8>=6.0.0 15 | # mypy>=1.0.0 16 | # jupyter>=1.0.0 17 | # notebook>=6.4.0 18 | 19 | # Documentation dependencies 20 | # sphinx>=5.0.0 21 | # sphinx-rtd-theme>=1.2.0 22 | # numpydoc>=1.5.0 23 | 24 | # GPU support (for CUDA-enabled PyTorch) 25 | # torch[cuda]>=2.0.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Johannes Kulick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /bayesian_changepoint_detection/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Bayesian Changepoint Detection Library. 4 | 5 | A PyTorch-based library for Bayesian changepoint detection in time series data. 6 | Implements both online and offline methods with GPU acceleration support. 7 | """ 8 | 9 | __version__ = '1.0.0' 10 | 11 | from .device import get_device, to_tensor, ensure_tensor, get_device_info 12 | from .bayesian_models import online_changepoint_detection, offline_changepoint_detection 13 | from .hazard_functions import constant_hazard 14 | from .priors import const_prior, geometric_prior, negative_binomial_prior 15 | from .online_likelihoods import StudentT, MultivariateT 16 | from . import online_likelihoods 17 | from . import offline_likelihoods 18 | from . import generate_data 19 | 20 | __all__ = [ 21 | 'get_device', 22 | 'to_tensor', 23 | 'ensure_tensor', 24 | 'get_device_info', 25 | 'online_changepoint_detection', 26 | 'offline_changepoint_detection', 27 | 'constant_hazard', 28 | 'const_prior', 29 | 'geometric_prior', 30 | 'negative_binomial_prior', 31 | 'StudentT', 32 | 'MultivariateT', 33 | 'online_likelihoods', 34 | 'offline_likelihoods', 35 | 'generate_data', 36 | ] 37 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: CD 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Python 3.10 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.10' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install setuptools wheel twine 27 | 28 | ## Prod PyPI 29 | - name: Build and publish 30 | env: 31 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 32 | TWINE_PASSWORD: ${{ secrets.PYPI_PROD_PASSWORD }} 33 | run: | 34 | python setup.py sdist bdist_wheel 35 | twine upload dist/* 36 | 37 | ## Test PyPI 38 | # - name: Build and publish 39 | # env: 40 | # TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 41 | # TWINE_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} 42 | # run: | 43 | # python setup.py sdist bdist_wheel 44 | # twine upload --repository testpypi dist/* 45 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | import seaborn 5 | 6 | import cProfile 7 | import bayesian_changepoint_detection.offline_changepoint_detection as offcd 8 | import bayesian_changepoint_detection.generate_data as gd 9 | from functools import partial 10 | 11 | if __name__ == '__main__': 12 | show_plot = True 13 | dim = 4 14 | if dim == 1: 15 | partition, data = gd.generate_normal_time_series(7, 50, 200) 16 | else: 17 | partition, data = gd.generate_multinormal_time_series(7, dim, 50, 200) 18 | changes = np.cumsum(partition) 19 | 20 | if show_plot: 21 | fig, ax = plt.subplots(figsize=[16,12]) 22 | for p in changes: 23 | ax.plot([p,p],[np.min(data),np.max(data)],'r') 24 | for d in range(dim): 25 | ax.plot(data[:,d]) 26 | plt.show() 27 | 28 | 29 | #Q, P, Pcp = offcd.offline_changepoint_detection(data,partial(offcd.const_prior, l=(len(data)+1)),offcd.gaussian_obs_log_likelihood, truncate=-20) 30 | #Q_ifm, P_ifm, Pcp_ifm = offcd.offline_changepoint_detection(data,partial(offcd.const_prior, l=(len(data)+1)),offcd.ifm_obs_log_likelihood,truncate=-20) 31 | Q_full, P_full, Pcp_full = offcd.offline_changepoint_detection(data,partial(offcd.const_prior, l=(len(data)+1)),offcd.fullcov_obs_log_likelihood, truncate=-50) 32 | 33 | if show_plot: 34 | fig, ax = plt.subplots(figsize=[18, 16]) 35 | ax = fig.add_subplot(2, 1, 1) 36 | for p in changes: 37 | ax.plot([p,p],[np.min(data),np.max(data)],'r') 38 | for d in range(dim): 39 | ax.plot(data[:,d]) 40 | ax = fig.add_subplot(2, 1, 2, sharex=ax) 41 | ax.plot(np.exp(Pcp_full).sum(0)) 42 | plt.show() 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | db.sqlite3 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # IPython 75 | profile_default/ 76 | ipython_config.py 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | .dmypy.json 109 | dmypy.json 110 | 111 | # Pyre type checker 112 | .pyre/ 113 | 114 | # pytype static type analyzer 115 | .pytype/ 116 | 117 | # Cython debug symbols 118 | cython_debug/ 119 | 120 | # PyCharm 121 | .idea/ 122 | 123 | # VS Code 124 | .vscode/ 125 | 126 | # Mac 127 | .DS_Store 128 | 129 | # Test output 130 | test.out 131 | debug_*.py 132 | 133 | # UV lock file (can be included or excluded based on preference) 134 | # uv.lock 135 | EOF < /dev/null 136 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import numpy as np 4 | from scipy.stats import multivariate_normal, norm 5 | 6 | from bayesian_changepoint_detection.bayesian_models import online_changepoint_detection 7 | from bayesian_changepoint_detection.hazard_functions import constant_hazard 8 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 9 | 10 | 11 | def test_multivariate(): 12 | np.random.seed(seed=34) 13 | # 10-dimensional multivariate normal, that shifts its mean at t=50, 100, and 150 14 | dataset = np.vstack(( 15 | multivariate_normal.rvs([0] * 10, size=50), 16 | multivariate_normal.rvs([4] * 10, size=50), 17 | multivariate_normal.rvs([0] * 10, size=50), 18 | multivariate_normal.rvs([-4] * 10, size=50) 19 | )) 20 | r, maxes = online_changepoint_detection( 21 | dataset, 22 | partial(constant_hazard, 50), 23 | MultivariateT(dims=10) 24 | ) 25 | 26 | # Assert that we detected the mean shifts 27 | # Check that changepoint probability is higher at breakpoints than nearby points 28 | for brkpt in [50, 100, 150]: 29 | # Find the maximum changepoint probability in a window around the breakpoint 30 | window_start = max(0, brkpt - 5) 31 | window_end = min(len(maxes) - 1, brkpt + 5) 32 | window_values = maxes[window_start:window_end+1] 33 | max_in_window = window_values.max().item() 34 | max_idx = window_start + window_values.argmax().item() 35 | # Assert that the detected changepoint is within 5 time steps of the true changepoint 36 | assert abs(max_idx - brkpt) <= 5 37 | 38 | 39 | def test_univariate(): 40 | np.random.seed(seed=34) 41 | # 10-dimensional univariate normal 42 | dataset = np.hstack((norm.rvs(0, size=50), norm.rvs(2, size=50))) 43 | r, maxes = online_changepoint_detection( 44 | dataset, 45 | partial(constant_hazard, 20), 46 | StudentT(0.1, .01, 1, 0) 47 | ) 48 | # Check that there's a significant drop in probability after the changepoint 49 | # The values are probabilities (0-1), not raw numbers 50 | assert maxes[50] / maxes[51] > 40 # Ratio test instead of difference 51 | -------------------------------------------------------------------------------- /bayesian_changepoint_detection/hazard_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hazard functions for Bayesian changepoint detection. 3 | 4 | Hazard functions specify the prior probability of a changepoint occurring 5 | at each time step, given the run length (time since last changepoint). 6 | """ 7 | 8 | import torch 9 | from typing import Union, Optional 10 | from .device import ensure_tensor, get_device 11 | 12 | 13 | def constant_hazard( 14 | lam: float, 15 | r: Union[torch.Tensor, int], 16 | device: Optional[Union[str, torch.device]] = None 17 | ) -> torch.Tensor: 18 | """ 19 | Constant hazard function for Bayesian online changepoint detection. 20 | 21 | This function returns a constant probability (1/lam) for a changepoint 22 | occurring at any time step, regardless of the current run length. 23 | 24 | Parameters 25 | ---------- 26 | lam : float 27 | The expected run length (higher values = lower changepoint probability). 28 | Must be positive. 29 | r : torch.Tensor or int 30 | Run length tensor or shape specification. If int, creates a tensor 31 | of that size filled with the constant hazard value. 32 | device : str, torch.device, or None, optional 33 | Device to place the output tensor on. 34 | 35 | Returns 36 | ------- 37 | torch.Tensor 38 | Tensor of hazard probabilities with the same shape as r. 39 | 40 | Examples 41 | -------- 42 | >>> import torch 43 | >>> # Create hazard for run lengths 0 to 9 44 | >>> hazard = constant_hazard(10.0, 10) 45 | >>> print(hazard) # All values will be 0.1 46 | 47 | >>> # Use with existing run length tensor 48 | >>> r = torch.arange(5) 49 | >>> hazard = constant_hazard(20.0, r) 50 | >>> print(hazard) # All values will be 0.05 51 | 52 | Notes 53 | ----- 54 | The constant hazard function assumes that the probability of a changepoint 55 | is independent of how long the current segment has been running. This is 56 | a common choice for modeling changepoints in stationary processes. 57 | """ 58 | if lam <= 0: 59 | raise ValueError("Lambda must be positive") 60 | 61 | device = get_device(device) 62 | 63 | if isinstance(r, int): 64 | return torch.full((r,), 1.0 / lam, device=device, dtype=torch.float32) 65 | else: 66 | r_tensor = ensure_tensor(r, device=device) 67 | return torch.full_like(r_tensor, 1.0 / lam, dtype=torch.float32) 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Setup script for bayesian-changepoint-detection package. 4 | 5 | This setup.py is maintained for backward compatibility. 6 | The main configuration is now in pyproject.toml. 7 | """ 8 | 9 | from setuptools import setup, find_packages 10 | 11 | # Read version from __init__.py 12 | def get_version(): 13 | """Get version from package __init__.py file.""" 14 | with open('bayesian_changepoint_detection/__init__.py', 'r') as f: 15 | for line in f: 16 | if line.startswith('__version__'): 17 | return line.split('=')[1].strip().strip('"').strip("'") 18 | return '1.0.0' 19 | 20 | # Read README for long description 21 | def get_long_description(): 22 | """Get long description from README.md file.""" 23 | try: 24 | with open('README.md', 'r', encoding='utf-8') as f: 25 | return f.read() 26 | except FileNotFoundError: 27 | return "Bayesian changepoint detection algorithms with PyTorch support" 28 | 29 | setup( 30 | name='bayesian-changepoint-detection', 31 | version=get_version(), 32 | description='Bayesian changepoint detection algorithms with PyTorch support', 33 | long_description=get_long_description(), 34 | long_description_content_type='text/markdown', 35 | author='Johannes Kulick', 36 | author_email='mail@johanneskulick.net', 37 | url='https://github.com/hildensia/bayesian_changepoint_detection', 38 | packages=find_packages(), 39 | python_requires='>=3.8.1', 40 | install_requires=[ 41 | 'torch>=2.0.0', 42 | 'numpy>=1.21.0', 43 | 'scipy>=1.7.0', 44 | 'matplotlib>=3.5.0', 45 | 'seaborn>=0.11.0', 46 | ], 47 | extras_require={ 48 | 'dev': [ 49 | 'pytest>=7.0.0', 50 | 'pytest-cov>=4.0.0', 51 | 'black>=23.0.0', 52 | 'isort>=5.12.0', 53 | 'flake8>=6.1.0', 54 | 'mypy>=1.0.0', 55 | 'jupyter>=1.0.0', 56 | 'notebook>=6.4.0', 57 | ], 58 | 'docs': [ 59 | 'sphinx>=5.0.0', 60 | 'sphinx-rtd-theme>=1.2.0', 61 | 'numpydoc>=1.5.0', 62 | ], 63 | 'gpu': [ 64 | 'torch[cuda]>=2.0.0', 65 | ], 66 | }, 67 | classifiers=[ 68 | 'Development Status :: 4 - Beta', 69 | 'Intended Audience :: Science/Research', 70 | 'License :: OSI Approved :: MIT License', 71 | 'Programming Language :: Python :: 3', 72 | 'Programming Language :: Python :: 3.8', 73 | 'Programming Language :: Python :: 3.9', 74 | 'Programming Language :: Python :: 3.10', 75 | 'Programming Language :: Python :: 3.11', 76 | 'Programming Language :: Python :: 3.12', 77 | 'Topic :: Scientific/Engineering :: Mathematics', 78 | 'Topic :: Scientific/Engineering :: Artificial Intelligence', 79 | ], 80 | keywords='bayesian changepoint detection time-series pytorch', 81 | project_urls={ 82 | 'Homepage': 'https://github.com/hildensia/bayesian_changepoint_detection', 83 | 'Repository': 'https://github.com/hildensia/bayesian_changepoint_detection', 84 | 'Issues': 'https://github.com/hildensia/bayesian_changepoint_detection/issues', 85 | }, 86 | ) 87 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = bayesian-changepoint-detection 3 | description = Bayesian changepoint detection algorithms with PyTorch support 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | url = https://github.com/hildensia/bayesian_changepoint_detection 7 | author = Johannes Kulick 8 | author_email = mail@johanneskulick.net 9 | license = MIT 10 | license_files = LICENSE 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Intended Audience :: Science/Research 14 | License :: OSI Approved :: MIT License 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.8 17 | Programming Language :: Python :: 3.9 18 | Programming Language :: Python :: 3.10 19 | Programming Language :: Python :: 3.11 20 | Programming Language :: Python :: 3.12 21 | Topic :: Scientific/Engineering :: Mathematics 22 | Topic :: Scientific/Engineering :: Artificial Intelligence 23 | 24 | [options] 25 | packages = find: 26 | python_requires = >=3.8.1 27 | include_package_data = True 28 | zip_safe = False 29 | install_requires = 30 | torch>=2.0.0 31 | numpy>=1.21.0 32 | scipy>=1.7.0 33 | matplotlib>=3.5.0 34 | seaborn>=0.11.0 35 | 36 | [options.packages.find] 37 | exclude = 38 | tests* 39 | docs* 40 | examples* 41 | 42 | [options.extras_require] 43 | dev = 44 | pytest>=7.0.0 45 | pytest-cov>=4.0.0 46 | black>=23.0.0 47 | isort>=5.12.0 48 | flake8>=6.1.0 49 | mypy>=1.0.0 50 | jupyter>=1.0.0 51 | notebook>=6.4.0 52 | docs = 53 | sphinx>=5.0.0 54 | sphinx-rtd-theme>=1.2.0 55 | numpydoc>=1.5.0 56 | gpu = 57 | torch[cuda]>=2.0.0 58 | 59 | [flake8] 60 | max-line-length = 88 61 | extend-ignore = E203, W503, E501 62 | exclude = 63 | .git, 64 | __pycache__, 65 | build, 66 | dist, 67 | *.egg-info, 68 | .venv, 69 | .tox, 70 | docs/_build 71 | 72 | [tool:pytest] 73 | testpaths = tests 74 | python_files = test_*.py *_test.py 75 | python_classes = Test* 76 | python_functions = test_* 77 | addopts = 78 | --strict-markers 79 | --strict-config 80 | --cov=bayesian_changepoint_detection 81 | --cov-report=term-missing 82 | --cov-report=html 83 | --cov-report=xml 84 | markers = 85 | slow: marks tests as slow (deselect with '-m "not slow"') 86 | gpu: marks tests that require GPU 87 | 88 | [mypy] 89 | python_version = 3.8 90 | warn_return_any = True 91 | warn_unused_configs = True 92 | disallow_untyped_defs = True 93 | disallow_incomplete_defs = True 94 | check_untyped_defs = True 95 | disallow_untyped_decorators = True 96 | no_implicit_optional = True 97 | warn_redundant_casts = True 98 | warn_unused_ignores = True 99 | warn_no_return = True 100 | warn_unreachable = True 101 | strict_equality = True 102 | 103 | [coverage:run] 104 | source = bayesian_changepoint_detection 105 | omit = 106 | */tests/* 107 | */test_* 108 | setup.py 109 | 110 | [coverage:report] 111 | exclude_lines = 112 | pragma: no cover 113 | def __repr__ 114 | if self.debug: 115 | if settings.DEBUG 116 | raise AssertionError 117 | raise NotImplementedError 118 | if 0: 119 | if __name__ == .__main__.: 120 | class .*\bProtocol\): 121 | @(abc\.)?abstractmethod -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "bayesian-changepoint-detection" 7 | version = "1.0.0" 8 | description = "Bayesian changepoint detection algorithms with PyTorch support" 9 | readme = "README.md" 10 | license = {text = "MIT"} 11 | authors = [ 12 | {name = "Johannes Kulick", email = "mail@johanneskulick.net"} 13 | ] 14 | maintainers = [ 15 | {name = "Johannes Kulick", email = "mail@johanneskulick.net"} 16 | ] 17 | keywords = ["bayesian", "changepoint", "detection", "time-series", "pytorch"] 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Intended Audience :: Science/Research", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Topic :: Scientific/Engineering :: Mathematics", 29 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 30 | ] 31 | requires-python = ">=3.8.1" 32 | dependencies = [ 33 | "torch>=2.0.0", 34 | "numpy>=1.21.0", 35 | "scipy>=1.7.0", 36 | "matplotlib>=3.5.0", 37 | "seaborn>=0.11.0", 38 | ] 39 | 40 | [project.optional-dependencies] 41 | dev = [ 42 | "pytest>=7.0.0", 43 | "pytest-cov>=4.0.0", 44 | "black>=23.0.0", 45 | "isort>=5.12.0", 46 | "flake8>=6.1.0", 47 | "mypy>=1.0.0", 48 | "jupyter>=1.0.0", 49 | "notebook>=6.4.0", 50 | ] 51 | docs = [ 52 | "sphinx>=5.0.0", 53 | "sphinx-rtd-theme>=1.2.0", 54 | "numpydoc>=1.5.0", 55 | ] 56 | gpu = [ 57 | "torch[cuda]>=2.0.0", 58 | ] 59 | 60 | [project.urls] 61 | Homepage = "https://github.com/hildensia/bayesian_changepoint_detection" 62 | Repository = "https://github.com/hildensia/bayesian_changepoint_detection" 63 | Issues = "https://github.com/hildensia/bayesian_changepoint_detection/issues" 64 | 65 | [tool.setuptools.packages.find] 66 | where = ["."] 67 | include = ["bayesian_changepoint_detection*"] 68 | 69 | [tool.black] 70 | line-length = 88 71 | target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] 72 | include = '\.pyi?$' 73 | extend-exclude = ''' 74 | /( 75 | # directories 76 | \.eggs 77 | | \.git 78 | | \.hg 79 | | \.mypy_cache 80 | | \.tox 81 | | \.venv 82 | | _build 83 | | buck-out 84 | | build 85 | | dist 86 | )/ 87 | ''' 88 | 89 | [tool.isort] 90 | profile = "black" 91 | multi_line_output = 3 92 | line_length = 88 93 | known_first_party = ["bayesian_changepoint_detection"] 94 | 95 | [tool.mypy] 96 | python_version = "3.8" 97 | warn_return_any = true 98 | warn_unused_configs = true 99 | disallow_untyped_defs = true 100 | disallow_incomplete_defs = true 101 | check_untyped_defs = true 102 | disallow_untyped_decorators = true 103 | no_implicit_optional = true 104 | warn_redundant_casts = true 105 | warn_unused_ignores = true 106 | warn_no_return = true 107 | warn_unreachable = true 108 | strict_equality = true 109 | 110 | [tool.pytest.ini_options] 111 | testpaths = ["tests"] 112 | python_files = ["test_*.py", "*_test.py"] 113 | python_classes = ["Test*"] 114 | python_functions = ["test_*"] 115 | addopts = [ 116 | "--strict-markers", 117 | "--strict-config", 118 | "--cov=bayesian_changepoint_detection", 119 | "--cov-report=term-missing", 120 | "--cov-report=html", 121 | "--cov-report=xml", 122 | ] 123 | markers = [ 124 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 125 | "gpu: marks tests that require GPU", 126 | ] 127 | 128 | [tool.coverage.run] 129 | source = ["bayesian_changepoint_detection"] 130 | omit = [ 131 | "*/tests/*", 132 | "*/test_*", 133 | "setup.py", 134 | ] 135 | 136 | [tool.coverage.report] 137 | exclude_lines = [ 138 | "pragma: no cover", 139 | "def __repr__", 140 | "if self.debug:", 141 | "if settings.DEBUG", 142 | "raise AssertionError", 143 | "raise NotImplementedError", 144 | "if 0:", 145 | "if __name__ == .__main__.:", 146 | "class .*\\bProtocol\\):", 147 | "@(abc\\.)?abstractmethod", 148 | ] -------------------------------------------------------------------------------- /quick_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Quick test script to verify bayesian_changepoint_detection installation and basic functionality. 4 | """ 5 | 6 | import torch 7 | from functools import partial 8 | from bayesian_changepoint_detection import ( 9 | online_changepoint_detection, 10 | offline_changepoint_detection, 11 | constant_hazard, 12 | const_prior, 13 | StudentT, 14 | get_device_info 15 | ) 16 | 17 | def test_online_detection(): 18 | """Test online changepoint detection.""" 19 | print("Testing online changepoint detection...") 20 | 21 | # Generate sample data 22 | torch.manual_seed(42) 23 | data = torch.cat([ 24 | torch.randn(50) + 0, # First segment: mean=0 25 | torch.randn(50) + 3, # Second segment: mean=3 26 | torch.randn(50) + 0, # Third segment: mean=0 27 | ]) 28 | 29 | # Set up the model 30 | hazard_func = partial(constant_hazard, 250) # Expected run length of 250 31 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 32 | 33 | # Run online changepoint detection 34 | run_length_probs, changepoint_probs = online_changepoint_detection( 35 | data, hazard_func, likelihood 36 | ) 37 | 38 | print(f"✓ Online detection completed") 39 | print(f" - Data shape: {data.shape}") 40 | print(f" - Max changepoint probability: {changepoint_probs.max().item():.4f}") 41 | print(f" - High probability indices: {torch.where(changepoint_probs > 0.01)[0].tolist()[:5]}...") 42 | 43 | return True 44 | 45 | def test_offline_detection(): 46 | """Test offline changepoint detection.""" 47 | print("\nTesting offline changepoint detection...") 48 | 49 | # Generate sample data 50 | torch.manual_seed(42) 51 | data = torch.cat([ 52 | torch.randn(30) + 0, # First segment: mean=0 53 | torch.randn(30) + 2, # Second segment: mean=2 54 | torch.randn(30) + 0, # Third segment: mean=0 55 | ]) 56 | 57 | # Use offline method for batch processing 58 | prior_func = partial(const_prior, p=1/(len(data)+1)) 59 | 60 | # Import the offline StudentT (if it exists) 61 | try: 62 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 63 | likelihood = OfflineStudentT() 64 | except ImportError: 65 | # Fallback to online StudentT 66 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 67 | 68 | try: 69 | Q, P, changepoint_log_probs = offline_changepoint_detection( 70 | data, prior_func, likelihood 71 | ) 72 | 73 | # Get changepoint probabilities 74 | changepoint_probs = torch.exp(changepoint_log_probs).sum(0) 75 | 76 | print(f"✓ Offline detection completed") 77 | print(f" - Data shape: {data.shape}") 78 | print(f" - Max changepoint probability: {changepoint_probs.max().item():.4f}") 79 | 80 | return True 81 | 82 | except Exception as e: 83 | print(f"⚠ Offline detection not fully implemented: {e}") 84 | return False 85 | 86 | def test_device_info(): 87 | """Test device information.""" 88 | print("\nTesting device information...") 89 | 90 | device_info = get_device_info() 91 | print(f"✓ Device info: {device_info}") 92 | 93 | return True 94 | 95 | def main(): 96 | """Run all tests.""" 97 | print("=" * 60) 98 | print("Bayesian Changepoint Detection - Quick Test") 99 | print("=" * 60) 100 | 101 | try: 102 | # Test device info 103 | test_device_info() 104 | 105 | # Test online detection 106 | test_online_detection() 107 | 108 | # Test offline detection 109 | test_offline_detection() 110 | 111 | print("\n" + "=" * 60) 112 | print("✅ All tests completed successfully!") 113 | print("The bayesian_changepoint_detection package is working correctly.") 114 | print("=" * 60) 115 | 116 | except Exception as e: 117 | print(f"\n❌ Test failed with error: {e}") 118 | print("Please check your installation and try again.") 119 | import traceback 120 | traceback.print_exc() 121 | 122 | if __name__ == "__main__": 123 | main() -------------------------------------------------------------------------------- /bayesian_changepoint_detection/device.py: -------------------------------------------------------------------------------- 1 | """ 2 | Device management utilities for PyTorch tensors. 3 | 4 | This module provides utilities for automatic device detection and tensor management 5 | across CPU and GPU platforms. 6 | """ 7 | 8 | import torch 9 | from typing import Optional, Union 10 | 11 | 12 | def get_device(device: Optional[Union[str, torch.device]] = None) -> torch.device: 13 | """ 14 | Get the appropriate PyTorch device. 15 | 16 | Parameters 17 | ---------- 18 | device : str, torch.device, or None, optional 19 | Desired device. If None, automatically selects the best available device. 20 | 21 | Returns 22 | ------- 23 | torch.device 24 | The selected device. 25 | 26 | Examples 27 | -------- 28 | >>> device = get_device() # Auto-select best device 29 | >>> device = get_device('cpu') # Force CPU 30 | >>> device = get_device('cuda:0') # Force specific GPU 31 | """ 32 | if device is None: 33 | if torch.cuda.is_available(): 34 | return torch.device('cuda') 35 | elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): 36 | return torch.device('mps') 37 | else: 38 | return torch.device('cpu') 39 | 40 | return torch.device(device) 41 | 42 | 43 | def to_tensor( 44 | data, 45 | device: Optional[Union[str, torch.device]] = None, 46 | dtype: Optional[torch.dtype] = None 47 | ) -> torch.Tensor: 48 | """ 49 | Convert data to PyTorch tensor on specified device. 50 | 51 | Parameters 52 | ---------- 53 | data : array-like 54 | Input data to convert to tensor. 55 | device : str, torch.device, or None, optional 56 | Target device for the tensor. 57 | dtype : torch.dtype, optional 58 | Desired data type for the tensor. 59 | 60 | Returns 61 | ------- 62 | torch.Tensor 63 | Converted tensor on the specified device. 64 | 65 | Examples 66 | -------- 67 | >>> import numpy as np 68 | >>> data = np.array([1, 2, 3]) 69 | >>> tensor = to_tensor(data) 70 | >>> tensor = to_tensor(data, device='cuda', dtype=torch.float32) 71 | """ 72 | if dtype is None: 73 | dtype = torch.float32 74 | 75 | device = get_device(device) 76 | 77 | if isinstance(data, torch.Tensor): 78 | return data.to(device=device, dtype=dtype) 79 | else: 80 | return torch.tensor(data, device=device, dtype=dtype) 81 | 82 | 83 | def ensure_tensor( 84 | data, 85 | device: Optional[Union[str, torch.device]] = None 86 | ) -> torch.Tensor: 87 | """ 88 | Ensure data is a PyTorch tensor, converting if necessary. 89 | 90 | Parameters 91 | ---------- 92 | data : array-like or torch.Tensor 93 | Input data. 94 | device : str, torch.device, or None, optional 95 | Target device for the tensor. 96 | 97 | Returns 98 | ------- 99 | torch.Tensor 100 | Tensor on the specified device. 101 | """ 102 | if not isinstance(data, torch.Tensor): 103 | return to_tensor(data, device=device) 104 | 105 | target_device = get_device(device) 106 | if data.device != target_device: 107 | return data.to(target_device) 108 | 109 | return data 110 | 111 | 112 | def get_device_info() -> dict: 113 | """ 114 | Get information about available devices. 115 | 116 | Returns 117 | ------- 118 | dict 119 | Dictionary containing device information. 120 | 121 | Examples 122 | -------- 123 | >>> info = get_device_info() 124 | >>> print(f"CUDA available: {info['cuda_available']}") 125 | >>> print(f"Device count: {info['device_count']}") 126 | """ 127 | info = { 128 | 'cuda_available': torch.cuda.is_available(), 129 | 'device_count': 0, 130 | 'current_device': None, 131 | 'mps_available': False, 132 | 'devices': [] 133 | } 134 | 135 | if torch.cuda.is_available(): 136 | info['device_count'] = torch.cuda.device_count() 137 | info['current_device'] = torch.cuda.current_device() 138 | info['devices'] = [f'cuda:{i}' for i in range(info['device_count'])] 139 | 140 | if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): 141 | info['mps_available'] = True 142 | info['devices'].append('mps') 143 | 144 | info['devices'].append('cpu') 145 | 146 | return info -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for device management utilities. 3 | """ 4 | 5 | import pytest 6 | import torch 7 | import numpy as np 8 | from bayesian_changepoint_detection.device import ( 9 | get_device, 10 | to_tensor, 11 | ensure_tensor, 12 | get_device_info 13 | ) 14 | 15 | 16 | class TestDeviceManagement: 17 | """Test device management functionality.""" 18 | 19 | def test_get_device_auto(self): 20 | """Test automatic device selection.""" 21 | device = get_device() 22 | assert isinstance(device, torch.device) 23 | assert device.type in ['cpu', 'cuda', 'mps'] 24 | 25 | def test_get_device_explicit(self): 26 | """Test explicit device specification.""" 27 | cpu_device = get_device('cpu') 28 | assert cpu_device == torch.device('cpu') 29 | 30 | # Test with torch.device object 31 | device_obj = torch.device('cpu') 32 | result = get_device(device_obj) 33 | assert result == device_obj 34 | 35 | def test_to_tensor_numpy(self): 36 | """Test conversion from numpy array to tensor.""" 37 | data = np.array([1, 2, 3]) 38 | tensor = to_tensor(data, device='cpu') 39 | 40 | assert isinstance(tensor, torch.Tensor) 41 | assert tensor.device == torch.device('cpu') 42 | assert torch.allclose(tensor, torch.tensor([1, 2, 3], dtype=torch.float32)) 43 | 44 | def test_to_tensor_list(self): 45 | """Test conversion from list to tensor.""" 46 | data = [1, 2, 3] 47 | tensor = to_tensor(data, device='cpu', dtype=torch.int64) 48 | 49 | assert isinstance(tensor, torch.Tensor) 50 | assert tensor.dtype == torch.int64 51 | assert torch.equal(tensor, torch.tensor([1, 2, 3], dtype=torch.int64)) 52 | 53 | def test_to_tensor_existing_tensor(self): 54 | """Test handling of existing tensor.""" 55 | original = torch.tensor([1, 2, 3], dtype=torch.int32) 56 | tensor = to_tensor(original, device='cpu', dtype=torch.float32) 57 | 58 | assert tensor.dtype == torch.float32 59 | assert torch.allclose(tensor, torch.tensor([1, 2, 3], dtype=torch.float32)) 60 | 61 | def test_ensure_tensor(self): 62 | """Test ensure_tensor functionality.""" 63 | # Test with numpy array 64 | data = np.array([1, 2, 3]) 65 | tensor = ensure_tensor(data, device='cpu') 66 | assert isinstance(tensor, torch.Tensor) 67 | 68 | # Test with existing tensor on same device 69 | existing = torch.tensor([1, 2, 3]) 70 | result = ensure_tensor(existing, device='cpu') 71 | assert result.device == torch.device('cpu') 72 | 73 | # Test with existing tensor on different device 74 | cpu_tensor = torch.tensor([1, 2, 3], device='cpu') 75 | result = ensure_tensor(cpu_tensor, device='cpu') 76 | assert result.device == torch.device('cpu') 77 | 78 | def test_get_device_info(self): 79 | """Test device information retrieval.""" 80 | info = get_device_info() 81 | 82 | assert isinstance(info, dict) 83 | assert 'cuda_available' in info 84 | assert 'device_count' in info 85 | assert 'devices' in info 86 | assert 'mps_available' in info 87 | 88 | # CPU should always be available 89 | assert 'cpu' in info['devices'] 90 | 91 | # Check consistency 92 | if info['cuda_available']: 93 | assert info['device_count'] > 0 94 | assert any('cuda' in device for device in info['devices']) 95 | else: 96 | assert info['device_count'] == 0 97 | 98 | 99 | @pytest.mark.gpu 100 | class TestGPUDevice: 101 | """Test GPU-specific functionality (requires GPU).""" 102 | 103 | @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") 104 | def test_cuda_device(self): 105 | """Test CUDA device functionality.""" 106 | device = get_device('cuda') 107 | assert device.type == 'cuda' 108 | 109 | # Test tensor creation on CUDA 110 | data = [1, 2, 3] 111 | tensor = to_tensor(data, device='cuda') 112 | assert tensor.device.type == 'cuda' 113 | 114 | @pytest.mark.skipif( 115 | not (hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()), 116 | reason="MPS not available" 117 | ) 118 | def test_mps_device(self): 119 | """Test MPS device functionality (Apple Silicon).""" 120 | device = get_device('mps') 121 | assert device.type == 'mps' 122 | 123 | # Test tensor creation on MPS 124 | data = [1, 2, 3] 125 | tensor = to_tensor(data, device='mps') 126 | assert tensor.device.type == 'mps' -------------------------------------------------------------------------------- /examples/simple_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple example demonstrating basic usage of bayesian_changepoint_detection. 4 | 5 | This example shows: 6 | 1. Online changepoint detection with synthetic data 7 | 2. Offline changepoint detection with synthetic data 8 | 3. Basic visualization of results 9 | """ 10 | 11 | import torch 12 | import matplotlib.pyplot as plt 13 | from functools import partial 14 | 15 | from bayesian_changepoint_detection import ( 16 | online_changepoint_detection, 17 | offline_changepoint_detection, 18 | constant_hazard, 19 | const_prior, 20 | StudentT 21 | ) 22 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 23 | 24 | def create_synthetic_data(): 25 | """Create synthetic data with known changepoints.""" 26 | torch.manual_seed(42) 27 | 28 | # Create data with obvious changepoints at positions 50 and 100 29 | segment1 = torch.randn(50) + 0 # mean=0, std=1 30 | segment2 = torch.randn(50) + 3 # mean=3, std=1 31 | segment3 = torch.randn(50) + 0 # mean=0, std=1 32 | 33 | data = torch.cat([segment1, segment2, segment3]) 34 | true_changepoints = [50, 100] 35 | 36 | return data, true_changepoints 37 | 38 | def run_online_detection(data): 39 | """Run online changepoint detection.""" 40 | print("Running online changepoint detection...") 41 | 42 | # Set up hazard function (prior over changepoint locations) 43 | hazard_func = partial(constant_hazard, 250) # Expected run length of 250 44 | 45 | # Set up likelihood model 46 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 47 | 48 | # Run detection 49 | run_length_probs, changepoint_probs = online_changepoint_detection( 50 | data, hazard_func, likelihood 51 | ) 52 | 53 | print(f"✓ Online detection completed") 54 | print(f" Max changepoint probability: {changepoint_probs.max().item():.4f}") 55 | 56 | return run_length_probs, changepoint_probs 57 | 58 | def run_offline_detection(data): 59 | """Run offline changepoint detection.""" 60 | print("Running offline changepoint detection...") 61 | 62 | # Set up prior function 63 | prior_func = partial(const_prior, p=1/(len(data)+1)) 64 | 65 | # Set up likelihood model 66 | likelihood = OfflineStudentT() 67 | 68 | # Run detection 69 | Q, P, changepoint_log_probs = offline_changepoint_detection( 70 | data, prior_func, likelihood 71 | ) 72 | 73 | # Get changepoint probabilities 74 | changepoint_probs = torch.exp(changepoint_log_probs).sum(0) 75 | 76 | print(f"✓ Offline detection completed") 77 | print(f" Max changepoint probability: {changepoint_probs.max().item():.4f}") 78 | 79 | return Q, P, changepoint_probs 80 | 81 | def plot_results(data, online_probs, offline_probs, true_changepoints): 82 | """Plot the results.""" 83 | print("Creating visualization...") 84 | 85 | plt.figure(figsize=(12, 8)) 86 | 87 | # Plot 1: Data 88 | plt.subplot(3, 1, 1) 89 | plt.plot(data.numpy(), 'b-', alpha=0.7, label='Data') 90 | for cp in true_changepoints: 91 | plt.axvline(x=cp, color='r', linestyle='--', alpha=0.8, label='True changepoint' if cp == true_changepoints[0] else '') 92 | plt.title('Synthetic Data with Known Changepoints') 93 | plt.ylabel('Value') 94 | plt.legend() 95 | plt.grid(True, alpha=0.3) 96 | 97 | # Plot 2: Online detection results 98 | plt.subplot(3, 1, 2) 99 | plt.plot(online_probs.numpy(), 'g-', label='Online changepoint probability') 100 | for cp in true_changepoints: 101 | plt.axvline(x=cp, color='r', linestyle='--', alpha=0.8) 102 | plt.title('Online Changepoint Detection Results') 103 | plt.ylabel('Probability') 104 | plt.legend() 105 | plt.grid(True, alpha=0.3) 106 | 107 | # Plot 3: Offline detection results 108 | plt.subplot(3, 1, 3) 109 | plt.plot(offline_probs.numpy(), 'm-', label='Offline changepoint probability') 110 | for cp in true_changepoints: 111 | plt.axvline(x=cp, color='r', linestyle='--', alpha=0.8) 112 | plt.title('Offline Changepoint Detection Results') 113 | plt.ylabel('Probability') 114 | plt.xlabel('Time') 115 | plt.legend() 116 | plt.grid(True, alpha=0.3) 117 | 118 | plt.tight_layout() 119 | plt.savefig('changepoint_detection_results.png', dpi=150, bbox_inches='tight') 120 | plt.show() 121 | 122 | print("✓ Visualization saved as 'changepoint_detection_results.png'") 123 | 124 | def main(): 125 | """Main function.""" 126 | print("=" * 60) 127 | print("Bayesian Changepoint Detection - Simple Example") 128 | print("=" * 60) 129 | 130 | # Create synthetic data 131 | data, true_changepoints = create_synthetic_data() 132 | print(f"Generated data with {len(data)} points") 133 | print(f"True changepoints at: {true_changepoints}") 134 | 135 | # Run online detection 136 | run_length_probs, online_changepoint_probs = run_online_detection(data) 137 | 138 | # Run offline detection 139 | Q, P, offline_changepoint_probs = run_offline_detection(data) 140 | 141 | # Find detected changepoints (simple peak detection) 142 | online_peaks = torch.where(online_changepoint_probs > 0.01)[0] 143 | offline_peaks = torch.where(offline_changepoint_probs > 0.01)[0] 144 | 145 | print(f"\nDetected changepoints:") 146 | print(f" Online method: {online_peaks.tolist()[:5]}...") # Show first 5 147 | print(f" Offline method: {offline_peaks.tolist()[:5]}...") # Show first 5 148 | 149 | # Create visualization 150 | try: 151 | plot_results(data, online_changepoint_probs, offline_changepoint_probs, true_changepoints) 152 | except ImportError: 153 | print("⚠ matplotlib not available for plotting") 154 | 155 | print("\n" + "=" * 60) 156 | print("✅ Example completed successfully!") 157 | print("=" * 60) 158 | 159 | if __name__ == "__main__": 160 | main() -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic usage example for PyTorch-based Bayesian changepoint detection. 4 | 5 | This example demonstrates the basic functionality of the refactored library 6 | using both online and offline changepoint detection methods. 7 | """ 8 | 9 | import torch 10 | from functools import partial 11 | import matplotlib.pyplot as plt 12 | 13 | # Import the refactored modules 14 | from bayesian_changepoint_detection import ( 15 | online_changepoint_detection, 16 | offline_changepoint_detection, 17 | get_device, 18 | get_device_info, 19 | ) 20 | from bayesian_changepoint_detection.online_likelihoods import StudentT 21 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 22 | from bayesian_changepoint_detection.hazard_functions import constant_hazard 23 | from bayesian_changepoint_detection.priors import const_prior 24 | from bayesian_changepoint_detection.generate_data import generate_mean_shift_example 25 | 26 | 27 | def main(): 28 | """Run basic usage examples.""" 29 | print("=" * 60) 30 | print("Bayesian Changepoint Detection - Basic Usage Example") 31 | print("=" * 60) 32 | 33 | # Display device information 34 | device = get_device() 35 | device_info = get_device_info() 36 | print(f"Using device: {device}") 37 | print(f"Available devices: {device_info['devices']}") 38 | print(f"CUDA available: {device_info['cuda_available']}") 39 | print() 40 | 41 | # Generate synthetic data with known changepoints 42 | print("Generating synthetic data...") 43 | torch.manual_seed(42) # For reproducibility 44 | 45 | # Create a simple step function with 4 segments 46 | partition, data = generate_mean_shift_example( 47 | num_segments=4, 48 | segment_length=100, 49 | shift_magnitude=3.0, 50 | noise_std=1.0, 51 | device=device 52 | ) 53 | 54 | print(f"Generated {len(data)} data points in {len(partition)} segments") 55 | print(f"True segment lengths: {partition.tolist()}") 56 | print(f"True changepoints at: {torch.cumsum(partition, 0)[:-1].tolist()}") 57 | print() 58 | 59 | # Example 1: Online Changepoint Detection 60 | print("=" * 40) 61 | print("Online Changepoint Detection") 62 | print("=" * 40) 63 | 64 | # Set up online detection 65 | hazard_func = partial(constant_hazard, 250) # Expected run length = 250 66 | online_likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device=device) 67 | 68 | print("Running online changepoint detection...") 69 | R, changepoint_probs = online_changepoint_detection( 70 | data.squeeze(), hazard_func, online_likelihood, device=device 71 | ) 72 | 73 | # Extract detected changepoints 74 | threshold = 0.1 75 | detected_online = torch.where(changepoint_probs > threshold)[0] 76 | print(f"Detected changepoints (threshold={threshold}): {detected_online.tolist()}") 77 | 78 | # Find peaks in changepoint probabilities 79 | peaks = [] 80 | for i in range(1, len(changepoint_probs) - 1): 81 | if (changepoint_probs[i] > changepoint_probs[i-1] and 82 | changepoint_probs[i] > changepoint_probs[i+1] and 83 | changepoint_probs[i] > 0.05): 84 | peaks.append(i) 85 | 86 | print(f"Detected peaks in changepoint probabilities: {peaks}") 87 | print() 88 | 89 | # Example 2: Offline Changepoint Detection 90 | print("=" * 40) 91 | print("Offline Changepoint Detection") 92 | print("=" * 40) 93 | 94 | # Set up offline detection 95 | prior_func = partial(const_prior, p=1/(len(data)+1)) 96 | offline_likelihood = OfflineStudentT(device=device) 97 | 98 | print("Running offline changepoint detection...") 99 | Q, P, Pcp = offline_changepoint_detection( 100 | data.squeeze(), prior_func, offline_likelihood, device=device 101 | ) 102 | 103 | # Get changepoint probabilities 104 | changepoint_probs_offline = torch.exp(Pcp).sum(0) 105 | detected_offline = torch.where(changepoint_probs_offline > 0.1)[0] 106 | print(f"Detected changepoints (threshold=0.1): {detected_offline.tolist()}") 107 | 108 | # Find peaks for offline detection 109 | offline_peaks = [] 110 | for i in range(len(changepoint_probs_offline)): 111 | if i == 0 or i == len(changepoint_probs_offline) - 1: 112 | continue 113 | if (changepoint_probs_offline[i] > changepoint_probs_offline[i-1] and 114 | changepoint_probs_offline[i] > changepoint_probs_offline[i+1] and 115 | changepoint_probs_offline[i] > 0.01): 116 | offline_peaks.append(i) 117 | 118 | print(f"Detected peaks in offline changepoint probabilities: {offline_peaks}") 119 | print() 120 | 121 | # Visualization (if matplotlib is available) 122 | try: 123 | print("Creating visualization...") 124 | fig, axes = plt.subplots(3, 1, figsize=(12, 10)) 125 | 126 | # Plot original data 127 | axes[0].plot(data.cpu().numpy(), 'b-', linewidth=1) 128 | axes[0].set_title('Original Time Series Data') 129 | axes[0].set_ylabel('Value') 130 | axes[0].grid(True, alpha=0.3) 131 | 132 | # Mark true changepoints 133 | true_changepoints = torch.cumsum(partition, 0)[:-1] 134 | for cp in true_changepoints: 135 | axes[0].axvline(cp.item(), color='red', linestyle='--', alpha=0.7, label='True changepoint') 136 | if len(true_changepoints) > 0: 137 | axes[0].legend() 138 | 139 | # Plot online detection results 140 | axes[1].plot(changepoint_probs.cpu().numpy(), 'g-', linewidth=2) 141 | axes[1].set_title('Online Changepoint Detection Probabilities') 142 | axes[1].set_ylabel('Probability') 143 | axes[1].grid(True, alpha=0.3) 144 | 145 | # Mark detected peaks 146 | for peak in peaks: 147 | axes[1].axvline(peak, color='orange', linestyle=':', alpha=0.8) 148 | 149 | # Plot offline detection results 150 | axes[2].plot(changepoint_probs_offline.cpu().numpy(), 'purple', linewidth=2) 151 | axes[2].set_title('Offline Changepoint Detection Probabilities') 152 | axes[2].set_ylabel('Probability') 153 | axes[2].set_xlabel('Time') 154 | axes[2].grid(True, alpha=0.3) 155 | 156 | # Mark detected peaks 157 | for peak in offline_peaks: 158 | axes[2].axvline(peak, color='orange', linestyle=':', alpha=0.8) 159 | 160 | plt.tight_layout() 161 | plt.savefig('changepoint_detection_example.png', dpi=150, bbox_inches='tight') 162 | print("Visualization saved as 'changepoint_detection_example.png'") 163 | 164 | except ImportError: 165 | print("Matplotlib not available - skipping visualization") 166 | 167 | print() 168 | print("=" * 60) 169 | print("Example completed successfully!") 170 | print("=" * 60) 171 | 172 | 173 | if __name__ == "__main__": 174 | main() -------------------------------------------------------------------------------- /bayesian_changepoint_detection/priors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Prior probability distributions for Bayesian changepoint detection. 3 | 4 | This module provides various prior distributions for modeling the probability 5 | of changepoints in time series data. 6 | """ 7 | 8 | import torch 9 | import torch.distributions as dist 10 | from typing import Union, Optional 11 | from .device import ensure_tensor, get_device 12 | 13 | 14 | def const_prior( 15 | t: Union[int, torch.Tensor], 16 | p: float = 0.25, 17 | device: Optional[Union[str, torch.device]] = None 18 | ) -> Union[float, torch.Tensor]: 19 | """ 20 | Constant prior probability for changepoints. 21 | 22 | Returns the same log probability for all time points, representing 23 | a uniform prior over changepoint locations. 24 | 25 | Parameters 26 | ---------- 27 | t : int or torch.Tensor 28 | Time index or tensor of time indices. 29 | p : float, optional 30 | Constant probability value (default: 0.25). 31 | Must be between 0 and 1. 32 | device : str, torch.device, or None, optional 33 | Device to place the output tensor on. 34 | 35 | Returns 36 | ------- 37 | float or torch.Tensor 38 | Log probability value(s). 39 | 40 | Examples 41 | -------- 42 | >>> # Single time point 43 | >>> log_prob = const_prior(5, p=0.1) 44 | >>> print(log_prob) # log(0.1) 45 | 46 | >>> # Multiple time points 47 | >>> t = torch.arange(10) 48 | >>> log_probs = const_prior(t, p=0.2) 49 | >>> print(log_probs.shape) # torch.Size([10]) 50 | 51 | Notes 52 | ----- 53 | The constant prior assumes that changepoints are equally likely 54 | at any time point in the series. 55 | """ 56 | if not 0 < p <= 1: 57 | raise ValueError("Probability p must be between 0 and 1") 58 | 59 | log_p = torch.log(torch.tensor(p, dtype=torch.float32)) 60 | 61 | if isinstance(t, int): 62 | return log_p.item() 63 | else: 64 | device = get_device(device) 65 | t_tensor = ensure_tensor(t, device=device) 66 | return log_p.expand_as(t_tensor) 67 | 68 | 69 | def geometric_prior( 70 | t: Union[int, torch.Tensor], 71 | p: float = 0.25, 72 | device: Optional[Union[str, torch.device]] = None 73 | ) -> Union[float, torch.Tensor]: 74 | """ 75 | Geometric prior for changepoint detection. 76 | 77 | Models the time between changepoints as following a geometric distribution, 78 | which is the discrete analogue of an exponential distribution. 79 | 80 | Parameters 81 | ---------- 82 | t : int or torch.Tensor 83 | Time index or tensor of time indices (number of trials). 84 | p : float, optional 85 | Probability of success (changepoint) at each trial (default: 0.25). 86 | Must be between 0 and 1. 87 | device : str, torch.device, or None, optional 88 | Device to place the output tensor on. 89 | 90 | Returns 91 | ------- 92 | float or torch.Tensor 93 | Log probability value(s) from the geometric distribution. 94 | 95 | Examples 96 | -------- 97 | >>> # Single time point 98 | >>> log_prob = geometric_prior(3, p=0.1) 99 | 100 | >>> # Multiple time points 101 | >>> t = torch.arange(1, 11) # 1 to 10 102 | >>> log_probs = geometric_prior(t, p=0.2) 103 | 104 | Notes 105 | ----- 106 | The geometric distribution models the number of trials needed for 107 | the first success, making it suitable for modeling inter-arrival 108 | times between changepoints. 109 | """ 110 | if not 0 < p <= 1: 111 | raise ValueError("Probability p must be between 0 and 1") 112 | 113 | device = get_device(device) 114 | 115 | if isinstance(t, int): 116 | if t <= 0: 117 | raise ValueError("Time index t must be positive for geometric prior") 118 | geom_dist = dist.Geometric(probs=torch.tensor(p, device=device)) 119 | return geom_dist.log_prob(torch.tensor(t, device=device)).item() 120 | else: 121 | t_tensor = ensure_tensor(t, device=device) 122 | if torch.any(t_tensor <= 0): 123 | raise ValueError("All time indices must be positive for geometric prior") 124 | geom_dist = dist.Geometric(probs=torch.tensor(p, device=device)) 125 | return geom_dist.log_prob(t_tensor) 126 | 127 | 128 | def negative_binomial_prior( 129 | t: Union[int, torch.Tensor], 130 | k: int = 1, 131 | p: float = 0.25, 132 | device: Optional[Union[str, torch.device]] = None 133 | ) -> Union[float, torch.Tensor]: 134 | """ 135 | Negative binomial prior for changepoint detection. 136 | 137 | Models the number of trials needed to achieve k successes (changepoints), 138 | generalizing the geometric distribution. 139 | 140 | Parameters 141 | ---------- 142 | t : int or torch.Tensor 143 | Time index or tensor of time indices (number of trials). 144 | k : int, optional 145 | Number of successes (changepoints) to achieve (default: 1). 146 | Must be positive. 147 | p : float, optional 148 | Probability of success at each trial (default: 0.25). 149 | Must be between 0 and 1. 150 | device : str, torch.device, or None, optional 151 | Device to place the output tensor on. 152 | 153 | Returns 154 | ------- 155 | float or torch.Tensor 156 | Log probability value(s) from the negative binomial distribution. 157 | 158 | Examples 159 | -------- 160 | >>> # Single time point 161 | >>> log_prob = negative_binomial_prior(5, k=2, p=0.1) 162 | 163 | >>> # Multiple time points 164 | >>> t = torch.arange(1, 11) 165 | >>> log_probs = negative_binomial_prior(t, k=3, p=0.2) 166 | 167 | Notes 168 | ----- 169 | When k=1, the negative binomial distribution reduces to the geometric 170 | distribution. Higher values of k model scenarios where multiple 171 | changepoints must occur before the process is considered complete. 172 | """ 173 | if not 0 < p <= 1: 174 | raise ValueError("Probability p must be between 0 and 1") 175 | if k <= 0: 176 | raise ValueError("Number of successes k must be positive") 177 | 178 | device = get_device(device) 179 | 180 | if isinstance(t, int): 181 | if t < k: 182 | return float('-inf') # Impossible to have k successes in fewer than k trials 183 | nb_dist = dist.NegativeBinomial( 184 | total_count=torch.tensor(k, device=device, dtype=torch.float32), 185 | probs=torch.tensor(p, device=device) 186 | ) 187 | return nb_dist.log_prob(torch.tensor(t - k, device=device)).item() 188 | else: 189 | t_tensor = ensure_tensor(t, device=device) 190 | # Set impossible cases to -inf 191 | log_probs = torch.full_like(t_tensor, float('-inf'), dtype=torch.float32) 192 | valid_mask = t_tensor >= k 193 | 194 | if torch.any(valid_mask): 195 | nb_dist = dist.NegativeBinomial( 196 | total_count=torch.tensor(k, device=device, dtype=torch.float32), 197 | probs=torch.tensor(p, device=device) 198 | ) 199 | log_probs[valid_mask] = nb_dist.log_prob(t_tensor[valid_mask] - k) 200 | 201 | return log_probs 202 | -------------------------------------------------------------------------------- /tests/test_priors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for prior probability distributions. 3 | """ 4 | 5 | import pytest 6 | import torch 7 | import numpy as np 8 | from bayesian_changepoint_detection.priors import ( 9 | const_prior, 10 | geometric_prior, 11 | negative_binomial_prior 12 | ) 13 | 14 | 15 | class TestConstPrior: 16 | """Test constant prior function.""" 17 | 18 | def test_single_timepoint(self): 19 | """Test constant prior for single time point.""" 20 | log_prob = const_prior(5, p=0.1) 21 | expected = np.log(0.1) 22 | assert abs(log_prob - expected) < 1e-6 23 | 24 | def test_multiple_timepoints(self): 25 | """Test constant prior for multiple time points.""" 26 | t = torch.arange(10) 27 | log_probs = const_prior(t, p=0.2) 28 | 29 | assert isinstance(log_probs, torch.Tensor) 30 | assert log_probs.shape == (10,) 31 | 32 | # All values should be the same 33 | expected = torch.log(torch.tensor(0.2)) 34 | assert torch.allclose(log_probs, expected.expand_as(log_probs)) 35 | 36 | def test_probability_validation(self): 37 | """Test probability parameter validation.""" 38 | # Valid probabilities 39 | const_prior(1, p=0.1) 40 | const_prior(1, p=1.0) 41 | 42 | # Invalid probabilities 43 | with pytest.raises(ValueError): 44 | const_prior(1, p=0.0) 45 | 46 | with pytest.raises(ValueError): 47 | const_prior(1, p=1.1) 48 | 49 | with pytest.raises(ValueError): 50 | const_prior(1, p=-0.1) 51 | 52 | 53 | class TestGeometricPrior: 54 | """Test geometric prior function.""" 55 | 56 | def test_single_timepoint(self): 57 | """Test geometric prior for single time point.""" 58 | log_prob = geometric_prior(3, p=0.1) 59 | 60 | # Should be finite and reasonable 61 | assert torch.isfinite(torch.tensor(log_prob)) 62 | assert log_prob < 0 # Log probability should be negative 63 | 64 | def test_multiple_timepoints(self): 65 | """Test geometric prior for multiple time points.""" 66 | t = torch.arange(1, 11) # 1 to 10 67 | log_probs = geometric_prior(t, p=0.2) 68 | 69 | assert isinstance(log_probs, torch.Tensor) 70 | assert log_probs.shape == (10,) 71 | assert torch.isfinite(log_probs).all() 72 | 73 | # Probabilities should generally decrease with time 74 | # (geometric distribution is decreasing) 75 | assert log_probs[0] > log_probs[-1] 76 | 77 | def test_time_validation(self): 78 | """Test time parameter validation.""" 79 | # Valid times 80 | geometric_prior(1, p=0.1) 81 | geometric_prior(torch.tensor([1, 2, 3]), p=0.1) 82 | 83 | # Invalid times 84 | with pytest.raises(ValueError): 85 | geometric_prior(0, p=0.1) 86 | 87 | with pytest.raises(ValueError): 88 | geometric_prior(-1, p=0.1) 89 | 90 | with pytest.raises(ValueError): 91 | geometric_prior(torch.tensor([0, 1, 2]), p=0.1) 92 | 93 | def test_probability_validation_geometric(self): 94 | """Test probability validation for geometric prior.""" 95 | with pytest.raises(ValueError): 96 | geometric_prior(1, p=0.0) 97 | 98 | with pytest.raises(ValueError): 99 | geometric_prior(1, p=1.1) 100 | 101 | 102 | class TestNegativeBinomialPrior: 103 | """Test negative binomial prior function.""" 104 | 105 | def test_single_timepoint(self): 106 | """Test negative binomial prior for single time point.""" 107 | log_prob = negative_binomial_prior(5, k=2, p=0.1) 108 | 109 | assert torch.isfinite(torch.tensor(log_prob)) 110 | assert log_prob < 0 # Log probability should be negative 111 | 112 | def test_multiple_timepoints(self): 113 | """Test negative binomial prior for multiple time points.""" 114 | t = torch.arange(1, 11) 115 | log_probs = negative_binomial_prior(t, k=2, p=0.2) 116 | 117 | assert isinstance(log_probs, torch.Tensor) 118 | assert log_probs.shape == (10,) 119 | 120 | # First k-1 values should be -inf 121 | assert log_probs[0] == float('-inf') # t=1, k=2, impossible 122 | assert torch.isfinite(log_probs[1:]).all() # t>=2 should be finite 123 | 124 | def test_impossible_cases(self): 125 | """Test handling of impossible cases (t < k).""" 126 | # Single impossible case 127 | log_prob = negative_binomial_prior(1, k=2, p=0.1) 128 | assert log_prob == float('-inf') 129 | 130 | # Multiple cases with some impossible 131 | t = torch.tensor([1, 2, 3, 4]) 132 | log_probs = negative_binomial_prior(t, k=3, p=0.1) 133 | 134 | assert log_probs[0] == float('-inf') # t=1, k=3 135 | assert log_probs[1] == float('-inf') # t=2, k=3 136 | assert torch.isfinite(log_probs[2]) # t=3, k=3, possible 137 | assert torch.isfinite(log_probs[3]) # t=4, k=3, possible 138 | 139 | def test_reduction_to_geometric(self): 140 | """Test that k=1 negative binomial follows expected pattern.""" 141 | t = torch.arange(1, 6) 142 | p = 0.3 143 | 144 | nb_log_probs = negative_binomial_prior(t, k=1, p=p) 145 | 146 | # PyTorch's NegativeBinomial(k, p) counts failures before k successes 147 | # So NB(t-k, k, p) = C(t-1, k-1) * p^k * (1-p)^(t-k) 148 | # For k=1: NB(t-1, 1, p) = p * (1-p)^(t-1) 149 | 150 | # Check that consecutive differences are constant (geometric property) 151 | differences = nb_log_probs[1:] - nb_log_probs[:-1] 152 | 153 | # All differences should be equal (within numerical tolerance) 154 | assert torch.allclose(differences, differences[0], atol=1e-5) 155 | 156 | # For NB with our parameterization, the difference is log(p) 157 | expected_diff = torch.log(torch.tensor(p)) 158 | assert torch.allclose(differences[0], expected_diff, atol=1e-5) 159 | 160 | def test_parameter_validation_nb(self): 161 | """Test parameter validation for negative binomial.""" 162 | # Valid parameters 163 | negative_binomial_prior(5, k=1, p=0.1) 164 | negative_binomial_prior(5, k=3, p=0.9) 165 | 166 | # Invalid k 167 | with pytest.raises(ValueError): 168 | negative_binomial_prior(5, k=0, p=0.1) 169 | 170 | with pytest.raises(ValueError): 171 | negative_binomial_prior(5, k=-1, p=0.1) 172 | 173 | # Invalid p 174 | with pytest.raises(ValueError): 175 | negative_binomial_prior(5, k=1, p=0.0) 176 | 177 | with pytest.raises(ValueError): 178 | negative_binomial_prior(5, k=1, p=1.1) 179 | 180 | 181 | class TestPriorDeviceHandling: 182 | """Test device handling for priors.""" 183 | 184 | @pytest.mark.gpu 185 | @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") 186 | def test_device_consistency(self): 187 | """Test that priors work correctly with different devices.""" 188 | t_cpu = torch.arange(1, 6) 189 | t_cuda = t_cpu.cuda() 190 | 191 | # Test const_prior 192 | log_probs_cpu = const_prior(t_cpu, p=0.1, device='cpu') 193 | log_probs_cuda = const_prior(t_cuda, p=0.1, device='cuda') 194 | 195 | assert log_probs_cpu.device.type == 'cpu' 196 | assert log_probs_cuda.device.type == 'cuda' 197 | assert torch.allclose(log_probs_cpu, log_probs_cuda.cpu()) 198 | 199 | # Test geometric_prior 200 | geom_cpu = geometric_prior(t_cpu, p=0.2, device='cpu') 201 | geom_cuda = geometric_prior(t_cuda, p=0.2, device='cuda') 202 | 203 | assert geom_cpu.device.type == 'cpu' 204 | assert geom_cuda.device.type == 'cuda' 205 | assert torch.allclose(geom_cpu, geom_cuda.cpu()) 206 | 207 | # Test negative_binomial_prior 208 | nb_cpu = negative_binomial_prior(t_cpu, k=2, p=0.3, device='cpu') 209 | nb_cuda = negative_binomial_prior(t_cuda, k=2, p=0.3, device='cuda') 210 | 211 | assert nb_cpu.device.type == 'cpu' 212 | assert nb_cuda.device.type == 'cuda' 213 | assert torch.allclose(nb_cpu, nb_cuda.cpu(), equal_nan=True) -------------------------------------------------------------------------------- /examples/multivariate_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Multivariate changepoint detection example. 4 | 5 | This example demonstrates changepoint detection for multivariate time series 6 | using the PyTorch-based implementation. 7 | """ 8 | 9 | import torch 10 | from functools import partial 11 | import matplotlib.pyplot as plt 12 | 13 | # Import the refactored modules 14 | from bayesian_changepoint_detection import ( 15 | online_changepoint_detection, 16 | get_device, 17 | ) 18 | from bayesian_changepoint_detection.online_likelihoods import MultivariateT 19 | from bayesian_changepoint_detection.hazard_functions import constant_hazard 20 | from bayesian_changepoint_detection.generate_data import ( 21 | generate_correlation_change_example, 22 | generate_multivariate_normal_time_series 23 | ) 24 | 25 | 26 | def correlation_change_example(): 27 | """Demonstrate detection of correlation changes.""" 28 | print("=" * 50) 29 | print("Correlation Change Detection Example") 30 | print("=" * 50) 31 | 32 | device = get_device() 33 | print(f"Using device: {device}") 34 | 35 | # Generate Xiang & Murphy's motivating example 36 | print("Generating correlation change example...") 37 | partition, data = generate_correlation_change_example( 38 | min_length=100, max_length=150, seed=42, device=device 39 | ) 40 | 41 | print(f"Generated {data.shape[0]} data points with {data.shape[1]} dimensions") 42 | print(f"Segment lengths: {partition.tolist()}") 43 | print(f"True changepoints at: {torch.cumsum(partition, 0)[:-1].tolist()}") 44 | print() 45 | 46 | # Set up multivariate online detection 47 | hazard_func = partial(constant_hazard, 200) # Expected run length = 200 48 | likelihood = MultivariateT(dims=2, device=device) 49 | 50 | print("Running multivariate changepoint detection...") 51 | R, changepoint_probs = online_changepoint_detection( 52 | data, hazard_func, likelihood, device=device 53 | ) 54 | 55 | # Extract detected changepoints 56 | threshold = 0.1 57 | detected = torch.where(changepoint_probs > threshold)[0] 58 | print(f"Detected changepoints (threshold={threshold}): {detected.tolist()}") 59 | 60 | # Find significant peaks 61 | peaks = [] 62 | for i in range(1, len(changepoint_probs) - 1): 63 | if (changepoint_probs[i] > changepoint_probs[i-1] and 64 | changepoint_probs[i] > changepoint_probs[i+1] and 65 | changepoint_probs[i] > 0.05): 66 | peaks.append(i) 67 | 68 | print(f"Detected peaks: {peaks}") 69 | 70 | # Visualization 71 | try: 72 | fig, axes = plt.subplots(3, 1, figsize=(12, 10)) 73 | 74 | # Plot multivariate data 75 | data_np = data.cpu().numpy() 76 | axes[0].plot(data_np[:, 0], 'b-', label='Dimension 1', linewidth=1) 77 | axes[0].plot(data_np[:, 1], 'r-', label='Dimension 2', linewidth=1) 78 | axes[0].set_title('Multivariate Time Series (Correlation Changes)') 79 | axes[0].set_ylabel('Value') 80 | axes[0].legend() 81 | axes[0].grid(True, alpha=0.3) 82 | 83 | # Mark true changepoints 84 | true_changepoints = torch.cumsum(partition, 0)[:-1] 85 | for cp in true_changepoints: 86 | axes[0].axvline(cp.item(), color='green', linestyle='--', alpha=0.7) 87 | 88 | # Plot correlation over time (approximate) 89 | window_size = 50 90 | correlations = [] 91 | for i in range(window_size, len(data)): 92 | window_data = data[i-window_size:i] 93 | corr = torch.corrcoef(window_data.T)[0, 1].item() 94 | correlations.append(corr) 95 | 96 | axes[1].plot(range(window_size, len(data)), correlations, 'purple', linewidth=2) 97 | axes[1].set_title(f'Rolling Correlation (window={window_size})') 98 | axes[1].set_ylabel('Correlation') 99 | axes[1].grid(True, alpha=0.3) 100 | axes[1].axhline(0, color='black', linestyle='-', alpha=0.3) 101 | 102 | # Mark true changepoints 103 | for cp in true_changepoints: 104 | axes[1].axvline(cp.item(), color='green', linestyle='--', alpha=0.7) 105 | 106 | # Plot changepoint probabilities 107 | axes[2].plot(changepoint_probs.cpu().numpy(), 'orange', linewidth=2) 108 | axes[2].set_title('Changepoint Detection Probabilities') 109 | axes[2].set_ylabel('Probability') 110 | axes[2].set_xlabel('Time') 111 | axes[2].grid(True, alpha=0.3) 112 | 113 | # Mark detected peaks 114 | for peak in peaks: 115 | axes[2].axvline(peak, color='red', linestyle=':', alpha=0.8) 116 | 117 | plt.tight_layout() 118 | plt.savefig('multivariate_changepoint_example.png', dpi=150, bbox_inches='tight') 119 | print("Visualization saved as 'multivariate_changepoint_example.png'") 120 | 121 | except ImportError: 122 | print("Matplotlib not available - skipping visualization") 123 | 124 | print() 125 | 126 | 127 | def general_multivariate_example(): 128 | """Demonstrate detection in general multivariate data.""" 129 | print("=" * 50) 130 | print("General Multivariate Detection Example") 131 | print("=" * 50) 132 | 133 | device = get_device() 134 | 135 | # Generate multivariate data with mean and covariance changes 136 | print("Generating multivariate time series...") 137 | partition, data = generate_multivariate_normal_time_series( 138 | num_segments=3, dims=4, min_length=80, max_length=120, seed=123, device=device 139 | ) 140 | 141 | print(f"Generated {data.shape[0]} data points with {data.shape[1]} dimensions") 142 | print(f"Segment lengths: {partition.tolist()}") 143 | print(f"True changepoints at: {torch.cumsum(partition, 0)[:-1].tolist()}") 144 | print() 145 | 146 | # Set up multivariate online detection 147 | hazard_func = partial(constant_hazard, 150) 148 | likelihood = MultivariateT(dims=4, device=device) 149 | 150 | print("Running multivariate changepoint detection...") 151 | R, changepoint_probs = online_changepoint_detection( 152 | data, hazard_func, likelihood, device=device 153 | ) 154 | 155 | # Extract detected changepoints 156 | threshold = 0.08 157 | detected = torch.where(changepoint_probs > threshold)[0] 158 | print(f"Detected changepoints (threshold={threshold}): {detected.tolist()}") 159 | 160 | # Find significant peaks 161 | peaks = [] 162 | for i in range(1, len(changepoint_probs) - 1): 163 | if (changepoint_probs[i] > changepoint_probs[i-1] and 164 | changepoint_probs[i] > changepoint_probs[i+1] and 165 | changepoint_probs[i] > 0.03): 166 | peaks.append(i) 167 | 168 | print(f"Detected peaks: {peaks}") 169 | 170 | # Visualization 171 | try: 172 | fig, axes = plt.subplots(2, 1, figsize=(12, 8)) 173 | 174 | # Plot first two dimensions of multivariate data 175 | data_np = data.cpu().numpy() 176 | for i in range(min(4, data.shape[1])): 177 | axes[0].plot(data_np[:, i], label=f'Dimension {i+1}', linewidth=1, alpha=0.8) 178 | 179 | axes[0].set_title('Multivariate Time Series (4D)') 180 | axes[0].set_ylabel('Value') 181 | axes[0].legend() 182 | axes[0].grid(True, alpha=0.3) 183 | 184 | # Mark true changepoints 185 | true_changepoints = torch.cumsum(partition, 0)[:-1] 186 | for cp in true_changepoints: 187 | axes[0].axvline(cp.item(), color='green', linestyle='--', alpha=0.7) 188 | 189 | # Plot changepoint probabilities 190 | axes[1].plot(changepoint_probs.cpu().numpy(), 'red', linewidth=2) 191 | axes[1].set_title('Changepoint Detection Probabilities') 192 | axes[1].set_ylabel('Probability') 193 | axes[1].set_xlabel('Time') 194 | axes[1].grid(True, alpha=0.3) 195 | 196 | # Mark detected peaks 197 | for peak in peaks: 198 | axes[1].axvline(peak, color='orange', linestyle=':', alpha=0.8) 199 | 200 | plt.tight_layout() 201 | plt.savefig('general_multivariate_example.png', dpi=150, bbox_inches='tight') 202 | print("Visualization saved as 'general_multivariate_example.png'") 203 | 204 | except ImportError: 205 | print("Matplotlib not available - skipping visualization") 206 | 207 | print() 208 | 209 | 210 | def main(): 211 | """Run multivariate examples.""" 212 | print("Multivariate Bayesian Changepoint Detection Examples") 213 | print("=" * 60) 214 | 215 | # Run correlation change example 216 | correlation_change_example() 217 | 218 | # Run general multivariate example 219 | general_multivariate_example() 220 | 221 | print("=" * 60) 222 | print("All multivariate examples completed successfully!") 223 | print("=" * 60) 224 | 225 | 226 | if __name__ == "__main__": 227 | main() -------------------------------------------------------------------------------- /tests/test_online_likelihoods.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for online likelihood functions. 3 | """ 4 | 5 | import pytest 6 | import torch 7 | import numpy as np 8 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 9 | 10 | 11 | class TestStudentT: 12 | """Test univariate Student's t-distribution likelihood.""" 13 | 14 | def test_initialization(self): 15 | """Test StudentT initialization.""" 16 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 17 | 18 | assert likelihood.alpha0 == 0.1 19 | assert likelihood.beta0 == 0.01 20 | assert likelihood.kappa0 == 1 21 | assert likelihood.mu0 == 0 22 | assert likelihood.t == 0 23 | 24 | def test_pdf_single_observation(self): 25 | """Test PDF computation for single observation.""" 26 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 27 | data = torch.tensor(1.0) 28 | 29 | log_probs = likelihood.pdf(data) 30 | 31 | assert isinstance(log_probs, torch.Tensor) 32 | assert log_probs.shape == (1,) 33 | assert torch.isfinite(log_probs).all() 34 | assert likelihood.t == 1 35 | 36 | def test_pdf_multiple_observations(self): 37 | """Test PDF computation for multiple observations.""" 38 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 39 | 40 | # First observation 41 | data1 = torch.tensor(1.0) 42 | log_probs1 = likelihood.pdf(data1) 43 | likelihood.update_theta(data1) 44 | 45 | # Second observation 46 | data2 = torch.tensor(2.0) 47 | log_probs2 = likelihood.pdf(data2) 48 | 49 | assert log_probs1.shape == (1,) 50 | assert log_probs2.shape == (2,) 51 | assert likelihood.t == 2 52 | 53 | def test_update_theta(self): 54 | """Test parameter updates.""" 55 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 56 | data = torch.tensor(1.0) 57 | 58 | # Store initial parameters 59 | initial_alpha = likelihood.alpha.clone() 60 | initial_beta = likelihood.beta.clone() 61 | initial_kappa = likelihood.kappa.clone() 62 | initial_mu = likelihood.mu.clone() 63 | 64 | likelihood.update_theta(data) 65 | 66 | # Parameters should have grown 67 | assert likelihood.alpha.shape[0] == 2 68 | assert likelihood.beta.shape[0] == 2 69 | assert likelihood.kappa.shape[0] == 2 70 | assert likelihood.mu.shape[0] == 2 71 | 72 | # First elements should be initial parameters 73 | assert torch.allclose(likelihood.alpha[0:1], initial_alpha) 74 | assert torch.allclose(likelihood.beta[0:1], initial_beta) 75 | assert torch.allclose(likelihood.kappa[0:1], initial_kappa) 76 | assert torch.allclose(likelihood.mu[0:1], initial_mu) 77 | 78 | def test_scalar_input_validation(self): 79 | """Test input validation for scalar data.""" 80 | likelihood = StudentT() 81 | 82 | # Should work with scalar 83 | data = torch.tensor(1.0) 84 | log_probs = likelihood.pdf(data) 85 | assert log_probs.shape == (1,) 86 | 87 | # Should fail with vector 88 | with pytest.raises(ValueError, match="scalar input"): 89 | vector_data = torch.tensor([1.0, 2.0]) 90 | likelihood.pdf(vector_data) 91 | 92 | @pytest.mark.gpu 93 | @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") 94 | def test_gpu_computation(self): 95 | """Test computation on GPU.""" 96 | likelihood = StudentT(device='cuda') 97 | data = torch.tensor(1.0, device='cuda') 98 | 99 | log_probs = likelihood.pdf(data) 100 | assert log_probs.device.type == 'cuda' 101 | 102 | likelihood.update_theta(data) 103 | assert likelihood.alpha.device.type == 'cuda' 104 | 105 | 106 | class TestMultivariateT: 107 | """Test multivariate Student's t-distribution likelihood.""" 108 | 109 | def test_initialization(self): 110 | """Test MultivariateT initialization.""" 111 | dims = 3 112 | likelihood = MultivariateT(dims=dims) 113 | 114 | assert likelihood.dims == dims 115 | assert likelihood.dof0 == dims + 1 116 | assert likelihood.kappa0 == 1.0 117 | assert likelihood.mu0.shape == (dims,) 118 | assert likelihood.scale0.shape == (dims, dims) 119 | assert likelihood.t == 0 120 | 121 | def test_initialization_with_custom_parameters(self): 122 | """Test initialization with custom parameters.""" 123 | dims = 2 124 | custom_mu = torch.tensor([1.0, 2.0]) 125 | custom_scale = torch.eye(2) * 2.0 126 | 127 | likelihood = MultivariateT( 128 | dims=dims, 129 | dof=10, 130 | kappa=2.0, 131 | mu=custom_mu, 132 | scale=custom_scale 133 | ) 134 | 135 | assert likelihood.dof0 == 10 136 | assert likelihood.kappa0 == 2.0 137 | assert torch.allclose(likelihood.mu0, custom_mu) 138 | assert torch.allclose(likelihood.scale0, custom_scale) 139 | 140 | def test_pdf_single_observation(self): 141 | """Test PDF computation for single observation.""" 142 | dims = 3 143 | likelihood = MultivariateT(dims=dims) 144 | data = torch.randn(dims) 145 | 146 | log_probs = likelihood.pdf(data) 147 | 148 | assert isinstance(log_probs, torch.Tensor) 149 | assert log_probs.shape == (1,) 150 | assert torch.isfinite(log_probs).all() 151 | assert likelihood.t == 1 152 | 153 | def test_pdf_multiple_observations(self): 154 | """Test PDF computation for multiple observations.""" 155 | dims = 2 156 | likelihood = MultivariateT(dims=dims) 157 | 158 | # First observation 159 | data1 = torch.randn(dims) 160 | log_probs1 = likelihood.pdf(data1) 161 | likelihood.update_theta(data1) 162 | 163 | # Second observation 164 | data2 = torch.randn(dims) 165 | log_probs2 = likelihood.pdf(data2) 166 | 167 | assert log_probs1.shape == (1,) 168 | assert log_probs2.shape == (2,) 169 | assert likelihood.t == 2 170 | 171 | def test_update_theta(self): 172 | """Test parameter updates for multivariate case.""" 173 | dims = 2 174 | likelihood = MultivariateT(dims=dims) 175 | data = torch.randn(dims) 176 | 177 | # Store initial shapes 178 | initial_mu_shape = likelihood.mu.shape 179 | initial_scale_shape = likelihood.scale.shape 180 | 181 | likelihood.update_theta(data) 182 | 183 | # Parameters should have grown 184 | assert likelihood.mu.shape == (2, dims) 185 | assert likelihood.scale.shape == (2, dims, dims) 186 | assert likelihood.dof.shape == (2,) 187 | assert likelihood.kappa.shape == (2,) 188 | 189 | # First elements should be initial parameters 190 | assert torch.allclose(likelihood.mu[0], likelihood.mu0) 191 | assert torch.allclose(likelihood.scale[0], likelihood.scale0) 192 | 193 | def test_input_shape_validation(self): 194 | """Test input shape validation.""" 195 | dims = 3 196 | likelihood = MultivariateT(dims=dims) 197 | 198 | # Should work with correct shape 199 | data = torch.randn(dims) 200 | log_probs = likelihood.pdf(data) 201 | assert log_probs.shape == (1,) 202 | 203 | # Should fail with wrong shape 204 | with pytest.raises(ValueError, match="Expected data shape"): 205 | wrong_data = torch.randn(dims + 1) 206 | likelihood.pdf(wrong_data) 207 | 208 | @pytest.mark.gpu 209 | @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") 210 | def test_gpu_computation_multivariate(self): 211 | """Test multivariate computation on GPU.""" 212 | dims = 2 213 | likelihood = MultivariateT(dims=dims, device='cuda') 214 | data = torch.randn(dims, device='cuda') 215 | 216 | log_probs = likelihood.pdf(data) 217 | assert log_probs.device.type == 'cuda' 218 | 219 | likelihood.update_theta(data) 220 | assert likelihood.mu.device.type == 'cuda' 221 | assert likelihood.scale.device.type == 'cuda' 222 | 223 | 224 | class TestConsistencyWithOriginal: 225 | """Test consistency with the original implementation behavior.""" 226 | 227 | def test_studentt_parameter_evolution(self): 228 | """Test that parameters evolve as expected.""" 229 | likelihood = StudentT(alpha=1.0, beta=1.0, kappa=1.0, mu=0.0) 230 | 231 | # Sequence of observations 232 | observations = [1.0, 2.0, -1.0, 0.5] 233 | 234 | for i, obs in enumerate(observations): 235 | data = torch.tensor(obs) 236 | log_prob = likelihood.pdf(data) 237 | 238 | # Should have i+1 run lengths 239 | assert log_prob.shape == (i + 1,) 240 | assert torch.isfinite(log_prob).all() 241 | 242 | likelihood.update_theta(data) 243 | 244 | # Parameters should grow 245 | assert likelihood.alpha.shape[0] == i + 2 246 | assert likelihood.beta.shape[0] == i + 2 247 | assert likelihood.kappa.shape[0] == i + 2 248 | assert likelihood.mu.shape[0] == i + 2 249 | 250 | def test_multivariate_parameter_evolution(self): 251 | """Test multivariate parameter evolution.""" 252 | dims = 2 253 | likelihood = MultivariateT(dims=dims, dof=dims+1, kappa=1.0) 254 | 255 | # Sequence of observations 256 | observations = [ 257 | torch.tensor([1.0, 0.5]), 258 | torch.tensor([-0.5, 1.5]), 259 | torch.tensor([0.0, 0.0]) 260 | ] 261 | 262 | for i, data in enumerate(observations): 263 | log_prob = likelihood.pdf(data) 264 | 265 | # Should have i+1 run lengths 266 | assert log_prob.shape == (i + 1,) 267 | assert torch.isfinite(log_prob).all() 268 | 269 | likelihood.update_theta(data) 270 | 271 | # Parameters should grow 272 | assert likelihood.mu.shape == (i + 2, dims) 273 | assert likelihood.scale.shape == (i + 2, dims, dims) 274 | assert likelihood.dof.shape == (i + 2,) 275 | assert likelihood.kappa.shape == (i + 2,) -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for the complete refactored system. 3 | """ 4 | 5 | import pytest 6 | import torch 7 | from functools import partial 8 | 9 | from bayesian_changepoint_detection import ( 10 | online_changepoint_detection, 11 | offline_changepoint_detection, 12 | get_device, 13 | ) 14 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 15 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 16 | from bayesian_changepoint_detection.hazard_functions import constant_hazard 17 | from bayesian_changepoint_detection.priors import const_prior 18 | from bayesian_changepoint_detection.generate_data import ( 19 | generate_mean_shift_example, 20 | generate_multivariate_normal_time_series 21 | ) 22 | 23 | 24 | class TestIntegration: 25 | """Integration tests for the complete system.""" 26 | 27 | def test_basic_online_detection_flow(self): 28 | """Test the complete online detection workflow.""" 29 | # Generate test data 30 | partition, data = generate_mean_shift_example( 31 | num_segments=3, segment_length=50, device='cpu' 32 | ) 33 | 34 | # Set up detection 35 | hazard_func = partial(constant_hazard, 100) 36 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cpu') 37 | 38 | # Run detection 39 | R, changepoint_probs = online_changepoint_detection( 40 | data.squeeze(), hazard_func, likelihood, device='cpu' 41 | ) 42 | 43 | # Verify outputs 44 | assert isinstance(R, torch.Tensor) 45 | assert isinstance(changepoint_probs, torch.Tensor) 46 | assert R.shape[1] == len(data) + 1 # T+1 time steps 47 | assert len(changepoint_probs) == len(data) + 1 48 | assert torch.all(R >= 0) # Probabilities should be non-negative 49 | assert torch.all(changepoint_probs >= 0) 50 | 51 | def test_basic_offline_detection_flow(self): 52 | """Test the complete offline detection workflow.""" 53 | # Generate test data 54 | partition, data = generate_mean_shift_example( 55 | num_segments=3, segment_length=50, device='cpu' 56 | ) 57 | 58 | # Set up detection 59 | prior_func = partial(const_prior, p=1/(len(data)+1)) 60 | likelihood = OfflineStudentT(device='cpu') 61 | 62 | # Run detection 63 | Q, P, Pcp = offline_changepoint_detection( 64 | data.squeeze(), prior_func, likelihood, device='cpu' 65 | ) 66 | 67 | # Verify outputs 68 | assert isinstance(Q, torch.Tensor) 69 | assert isinstance(P, torch.Tensor) 70 | assert isinstance(Pcp, torch.Tensor) 71 | assert len(Q) == len(data) 72 | assert P.shape == (len(data), len(data)) 73 | assert Pcp.shape == (len(data) - 1, len(data) - 1) 74 | 75 | def test_multivariate_online_detection(self): 76 | """Test multivariate online detection.""" 77 | # Generate multivariate test data 78 | partition, data = generate_multivariate_normal_time_series( 79 | num_segments=2, dims=3, min_length=30, max_length=50, device='cpu' 80 | ) 81 | 82 | # Set up detection 83 | hazard_func = partial(constant_hazard, 80) 84 | likelihood = MultivariateT(dims=3, device='cpu') 85 | 86 | # Run detection 87 | R, changepoint_probs = online_changepoint_detection( 88 | data, hazard_func, likelihood, device='cpu' 89 | ) 90 | 91 | # Verify outputs 92 | assert isinstance(R, torch.Tensor) 93 | assert isinstance(changepoint_probs, torch.Tensor) 94 | assert R.shape[1] == data.shape[0] + 1 95 | assert len(changepoint_probs) == data.shape[0] + 1 96 | 97 | def test_device_consistency(self): 98 | """Test that device handling is consistent throughout.""" 99 | if not torch.cuda.is_available(): 100 | pytest.skip("CUDA not available") 101 | 102 | # Generate data on CPU 103 | partition, data = generate_mean_shift_example( 104 | num_segments=2, segment_length=30, device='cpu' 105 | ) 106 | 107 | # Run detection on GPU 108 | hazard_func = partial(constant_hazard, 60) 109 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cuda') 110 | 111 | R, changepoint_probs = online_changepoint_detection( 112 | data.squeeze(), hazard_func, likelihood, device='cuda' 113 | ) 114 | 115 | # Verify results are on GPU 116 | assert R.device.type == 'cuda' 117 | assert changepoint_probs.device.type == 'cuda' 118 | 119 | def test_backward_compatibility(self): 120 | """Test that the refactored code maintains backward compatibility.""" 121 | # This test ensures the new API can handle the same patterns as the old code 122 | 123 | # Generate test data using new function 124 | partition, data = generate_mean_shift_example( 125 | num_segments=3, segment_length=40, device='cpu' 126 | ) 127 | 128 | # Use the new API in a way similar to the old API 129 | hazard_func = partial(constant_hazard, 80) 130 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 131 | 132 | # This should work without specifying device explicitly 133 | R, changepoint_probs = online_changepoint_detection( 134 | data.squeeze(), hazard_func, likelihood 135 | ) 136 | 137 | # Should produce reasonable results 138 | assert torch.isfinite(R).all() 139 | assert torch.isfinite(changepoint_probs).all() 140 | assert changepoint_probs.max() <= 1.0 141 | assert changepoint_probs.min() >= 0.0 142 | 143 | def test_numerical_stability(self): 144 | """Test numerical stability with extreme parameter values.""" 145 | # Generate data with small variance 146 | data = torch.randn(100) * 0.01 147 | 148 | # Use extreme parameter values 149 | hazard_func = partial(constant_hazard, 1000) # Very low hazard 150 | likelihood = StudentT(alpha=0.001, beta=0.001, kappa=0.1, mu=0) 151 | 152 | # Should not produce NaN or infinite values 153 | R, changepoint_probs = online_changepoint_detection( 154 | data, hazard_func, likelihood 155 | ) 156 | 157 | assert torch.isfinite(R).all() 158 | assert torch.isfinite(changepoint_probs).all() 159 | 160 | def test_empty_and_small_data(self): 161 | """Test handling of edge cases with small datasets.""" 162 | # Very small dataset 163 | data = torch.tensor([1.0, 2.0]) 164 | 165 | hazard_func = partial(constant_hazard, 10) 166 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 167 | 168 | # Should handle gracefully 169 | R, changepoint_probs = online_changepoint_detection( 170 | data, hazard_func, likelihood 171 | ) 172 | 173 | assert len(changepoint_probs) == 3 # len(data) + 1 174 | assert torch.isfinite(R).all() 175 | assert torch.isfinite(changepoint_probs).all() 176 | 177 | def test_performance_scaling(self): 178 | """Test that performance scales reasonably with data size.""" 179 | import time 180 | 181 | sizes = [50, 100, 200] 182 | times = [] 183 | 184 | for size in sizes: 185 | # Generate data 186 | partition, data = generate_mean_shift_example( 187 | num_segments=2, segment_length=size//2, device='cpu' 188 | ) 189 | 190 | # Set up detection 191 | hazard_func = partial(constant_hazard, size) 192 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 193 | 194 | # Time the detection 195 | start_time = time.time() 196 | R, changepoint_probs = online_changepoint_detection( 197 | data.squeeze(), hazard_func, likelihood 198 | ) 199 | elapsed = time.time() - start_time 200 | times.append(elapsed) 201 | 202 | # Performance should not degrade catastrophically 203 | # (This is a rough check - exact scaling depends on implementation details) 204 | assert times[-1] < times[0] * 20 # Should not be more than 20x slower for 4x data 205 | 206 | 207 | @pytest.mark.slow 208 | class TestPerformanceIntegration: 209 | """Performance-focused integration tests.""" 210 | 211 | def test_large_dataset_online(self): 212 | """Test online detection on a larger dataset.""" 213 | # Generate larger dataset 214 | partition, data = generate_mean_shift_example( 215 | num_segments=5, segment_length=200, device='cpu' 216 | ) 217 | 218 | hazard_func = partial(constant_hazard, 500) 219 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 220 | 221 | # Should complete without memory issues 222 | R, changepoint_probs = online_changepoint_detection( 223 | data.squeeze(), hazard_func, likelihood 224 | ) 225 | 226 | assert torch.isfinite(R).all() 227 | assert torch.isfinite(changepoint_probs).all() 228 | 229 | def test_large_dataset_multivariate(self): 230 | """Test multivariate detection on larger dataset.""" 231 | # Generate larger multivariate dataset 232 | partition, data = generate_multivariate_normal_time_series( 233 | num_segments=3, dims=5, min_length=100, max_length=150, device='cpu' 234 | ) 235 | 236 | hazard_func = partial(constant_hazard, 300) 237 | likelihood = MultivariateT(dims=5) 238 | 239 | # Should complete without memory issues 240 | R, changepoint_probs = online_changepoint_detection( 241 | data, hazard_func, likelihood 242 | ) 243 | 244 | assert torch.isfinite(R).all() 245 | assert torch.isfinite(changepoint_probs).all() 246 | 247 | 248 | class TestRegressionAgainstOriginal: 249 | """Tests to ensure refactored code produces similar results to original.""" 250 | 251 | def test_simple_detection_regression(self): 252 | """Test that we get reasonable changepoint detection on known data.""" 253 | # Create data with obvious changepoints 254 | segment1 = torch.zeros(50) 255 | segment2 = torch.ones(50) * 5 # Clear shift 256 | segment3 = torch.zeros(50) 257 | data = torch.cat([segment1, segment2, segment3]) 258 | 259 | # Add small amount of noise 260 | data += torch.randn_like(data) * 0.1 261 | 262 | # Run detection with more sensitive parameters 263 | hazard_func = partial(constant_hazard, 50) # More frequent changepoints expected 264 | likelihood = StudentT(alpha=0.01, beta=0.01, kappa=1, mu=0) # More sensitive 265 | 266 | R, changepoint_probs = online_changepoint_detection( 267 | data, hazard_func, likelihood 268 | ) 269 | 270 | # Should detect changepoints around positions 50 and 100 271 | # The algorithm always has changepoint_probs[0] = 1.0 by definition 272 | # So we look for significant increases in changepoint probability 273 | 274 | # Find indices with high changepoint probability (excluding index 0) 275 | high_prob_indices = torch.where(changepoint_probs[1:] > 0.01)[0] + 1 276 | 277 | # Check if we detected changepoints near positions 50 and 100 278 | detected_near_50 = any(45 <= idx <= 55 for idx in high_prob_indices) 279 | detected_near_100 = any(95 <= idx <= 105 for idx in high_prob_indices) 280 | 281 | # Should detect at least one of the changepoints 282 | assert detected_near_50 or detected_near_100, f"No changepoints detected near 50 or 100. High prob indices: {high_prob_indices.tolist()[:10]}..." 283 | 284 | -------------------------------------------------------------------------------- /examples/gpu_acceleration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | GPU acceleration example for Bayesian changepoint detection. 4 | 5 | This example demonstrates the performance benefits of using GPU acceleration 6 | with the PyTorch-based implementation. 7 | """ 8 | 9 | import torch 10 | import time 11 | from functools import partial 12 | 13 | # Import the refactored modules 14 | from bayesian_changepoint_detection import ( 15 | online_changepoint_detection, 16 | get_device, 17 | get_device_info, 18 | to_tensor, 19 | ) 20 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 21 | from bayesian_changepoint_detection.hazard_functions import constant_hazard 22 | from bayesian_changepoint_detection.generate_data import ( 23 | generate_mean_shift_example, 24 | generate_multivariate_normal_time_series 25 | ) 26 | 27 | 28 | def benchmark_univariate(data_length=1000, num_runs=3): 29 | """Benchmark univariate changepoint detection on CPU vs GPU.""" 30 | print(f"Benchmarking Univariate Detection (data length: {data_length})") 31 | print("-" * 50) 32 | 33 | # Generate test data 34 | torch.manual_seed(42) 35 | partition, data = generate_mean_shift_example( 36 | num_segments=5, 37 | segment_length=data_length//5, 38 | shift_magnitude=2.0, 39 | device='cpu' # Start on CPU 40 | ) 41 | 42 | print(f"Generated {len(data)} data points") 43 | 44 | # Benchmark on CPU 45 | print("Testing CPU performance...") 46 | cpu_times = [] 47 | 48 | for run in range(num_runs): 49 | # Move data to CPU and create CPU likelihood 50 | data_cpu = data.to('cpu') 51 | hazard_func = partial(constant_hazard, 250) 52 | likelihood_cpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cpu') 53 | 54 | start_time = time.time() 55 | R, changepoint_probs = online_changepoint_detection( 56 | data_cpu.squeeze(), hazard_func, likelihood_cpu, device='cpu' 57 | ) 58 | cpu_time = time.time() - start_time 59 | cpu_times.append(cpu_time) 60 | print(f" Run {run+1}: {cpu_time:.3f}s") 61 | 62 | avg_cpu_time = sum(cpu_times) / len(cpu_times) 63 | print(f"Average CPU time: {avg_cpu_time:.3f}s") 64 | 65 | # Benchmark on GPU (if available) 66 | device_info = get_device_info() 67 | if device_info['cuda_available']: 68 | print("\nTesting GPU performance...") 69 | gpu_times = [] 70 | 71 | for run in range(num_runs): 72 | # Move data to GPU and create GPU likelihood 73 | data_gpu = data.to('cuda') 74 | hazard_func = partial(constant_hazard, 250) 75 | likelihood_gpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cuda') 76 | 77 | # Warm up GPU 78 | if run == 0: 79 | R_warmup, _ = online_changepoint_detection( 80 | data_gpu.squeeze(), hazard_func, likelihood_gpu, device='cuda' 81 | ) 82 | torch.cuda.synchronize() # Ensure GPU is ready 83 | 84 | start_time = time.time() 85 | R, changepoint_probs = online_changepoint_detection( 86 | data_gpu.squeeze(), hazard_func, likelihood_gpu, device='cuda' 87 | ) 88 | torch.cuda.synchronize() # Wait for GPU to finish 89 | gpu_time = time.time() - start_time 90 | gpu_times.append(gpu_time) 91 | print(f" Run {run+1}: {gpu_time:.3f}s") 92 | 93 | avg_gpu_time = sum(gpu_times) / len(gpu_times) 94 | print(f"Average GPU time: {avg_gpu_time:.3f}s") 95 | 96 | speedup = avg_cpu_time / avg_gpu_time 97 | print(f"\nSpeedup: {speedup:.2f}x") 98 | 99 | # Verify results are consistent 100 | data_cpu = data.to('cpu') 101 | data_gpu = data.to('cuda') 102 | 103 | likelihood_cpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cpu') 104 | likelihood_gpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cuda') 105 | 106 | R_cpu, cp_cpu = online_changepoint_detection( 107 | data_cpu.squeeze(), hazard_func, likelihood_cpu, device='cpu' 108 | ) 109 | R_gpu, cp_gpu = online_changepoint_detection( 110 | data_gpu.squeeze(), hazard_func, likelihood_gpu, device='cuda' 111 | ) 112 | 113 | # Check if results are close (allowing for small numerical differences) 114 | cp_gpu_cpu = cp_gpu.to('cpu') 115 | max_diff = torch.max(torch.abs(cp_cpu - cp_gpu_cpu)).item() 116 | print(f"Maximum difference between CPU and GPU results: {max_diff:.2e}") 117 | 118 | else: 119 | print("\nGPU not available - skipping GPU benchmark") 120 | 121 | print() 122 | 123 | 124 | def benchmark_multivariate(dims=5, data_length=500, num_runs=3): 125 | """Benchmark multivariate changepoint detection on CPU vs GPU.""" 126 | print(f"Benchmarking Multivariate Detection (dims: {dims}, length: {data_length})") 127 | print("-" * 60) 128 | 129 | # Generate test data 130 | torch.manual_seed(42) 131 | partition, data = generate_multivariate_normal_time_series( 132 | num_segments=3, 133 | dims=dims, 134 | min_length=data_length//3, 135 | max_length=data_length//3 + 20, 136 | device='cpu' 137 | ) 138 | 139 | print(f"Generated {data.shape[0]} x {data.shape[1]} data points") 140 | 141 | # Benchmark on CPU 142 | print("Testing CPU performance...") 143 | cpu_times = [] 144 | 145 | for run in range(num_runs): 146 | data_cpu = data.to('cpu') 147 | hazard_func = partial(constant_hazard, 200) 148 | likelihood_cpu = MultivariateT(dims=dims, device='cpu') 149 | 150 | start_time = time.time() 151 | R, changepoint_probs = online_changepoint_detection( 152 | data_cpu, hazard_func, likelihood_cpu, device='cpu' 153 | ) 154 | cpu_time = time.time() - start_time 155 | cpu_times.append(cpu_time) 156 | print(f" Run {run+1}: {cpu_time:.3f}s") 157 | 158 | avg_cpu_time = sum(cpu_times) / len(cpu_times) 159 | print(f"Average CPU time: {avg_cpu_time:.3f}s") 160 | 161 | # Benchmark on GPU (if available) 162 | device_info = get_device_info() 163 | if device_info['cuda_available']: 164 | print("\nTesting GPU performance...") 165 | gpu_times = [] 166 | 167 | for run in range(num_runs): 168 | data_gpu = data.to('cuda') 169 | hazard_func = partial(constant_hazard, 200) 170 | likelihood_gpu = MultivariateT(dims=dims, device='cuda') 171 | 172 | # Warm up GPU 173 | if run == 0: 174 | R_warmup, _ = online_changepoint_detection( 175 | data_gpu, hazard_func, likelihood_gpu, device='cuda' 176 | ) 177 | torch.cuda.synchronize() 178 | 179 | start_time = time.time() 180 | R, changepoint_probs = online_changepoint_detection( 181 | data_gpu, hazard_func, likelihood_gpu, device='cuda' 182 | ) 183 | torch.cuda.synchronize() 184 | gpu_time = time.time() - start_time 185 | gpu_times.append(gpu_time) 186 | print(f" Run {run+1}: {gpu_time:.3f}s") 187 | 188 | avg_gpu_time = sum(gpu_times) / len(gpu_times) 189 | print(f"Average GPU time: {avg_gpu_time:.3f}s") 190 | 191 | speedup = avg_cpu_time / avg_gpu_time 192 | print(f"\nSpeedup: {speedup:.2f}x") 193 | 194 | else: 195 | print("\nGPU not available - skipping GPU benchmark") 196 | 197 | print() 198 | 199 | 200 | def memory_usage_demo(): 201 | """Demonstrate memory usage on GPU.""" 202 | device_info = get_device_info() 203 | if not device_info['cuda_available']: 204 | print("GPU not available - skipping memory usage demo") 205 | return 206 | 207 | print("GPU Memory Usage Demonstration") 208 | print("-" * 40) 209 | 210 | def print_memory_stats(): 211 | allocated = torch.cuda.memory_allocated() / (1024**2) # MB 212 | cached = torch.cuda.memory_reserved() / (1024**2) # MB 213 | print(f" Allocated: {allocated:.1f} MB, Cached: {cached:.1f} MB") 214 | 215 | print("Initial memory usage:") 216 | print_memory_stats() 217 | 218 | # Create progressively larger datasets 219 | sizes = [100, 500, 1000, 2000] 220 | 221 | for size in sizes: 222 | print(f"\nProcessing dataset of size {size}:") 223 | 224 | # Generate data 225 | torch.manual_seed(42) 226 | partition, data = generate_mean_shift_example( 227 | num_segments=4, 228 | segment_length=size//4, 229 | device='cuda' 230 | ) 231 | 232 | print(f" After data generation:") 233 | print_memory_stats() 234 | 235 | # Run detection 236 | hazard_func = partial(constant_hazard, 250) 237 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cuda') 238 | 239 | R, changepoint_probs = online_changepoint_detection( 240 | data.squeeze(), hazard_func, likelihood, device='cuda' 241 | ) 242 | 243 | print(f" After detection:") 244 | print_memory_stats() 245 | 246 | # Clean up 247 | del data, R, changepoint_probs, likelihood 248 | torch.cuda.empty_cache() 249 | 250 | print(f" After cleanup:") 251 | print_memory_stats() 252 | 253 | 254 | def device_switching_demo(): 255 | """Demonstrate switching between devices.""" 256 | print("Device Switching Demonstration") 257 | print("-" * 35) 258 | 259 | # Generate data 260 | torch.manual_seed(42) 261 | partition, data = generate_mean_shift_example( 262 | num_segments=3, 263 | segment_length=100, 264 | device='cpu' # Start on CPU 265 | ) 266 | 267 | print(f"Initial data device: {data.device}") 268 | 269 | # Process on CPU 270 | print("\nProcessing on CPU...") 271 | hazard_func = partial(constant_hazard, 200) 272 | likelihood_cpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cpu') 273 | 274 | start_time = time.time() 275 | R_cpu, cp_cpu = online_changepoint_detection( 276 | data.squeeze(), hazard_func, likelihood_cpu, device='cpu' 277 | ) 278 | cpu_time = time.time() - start_time 279 | print(f"CPU processing time: {cpu_time:.3f}s") 280 | 281 | # Switch to GPU if available 282 | device_info = get_device_info() 283 | if device_info['cuda_available']: 284 | print("\nSwitching to GPU...") 285 | 286 | # Move data to GPU 287 | data_gpu = data.to('cuda') 288 | print(f"Data moved to: {data_gpu.device}") 289 | 290 | # Create GPU likelihood 291 | likelihood_gpu = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device='cuda') 292 | 293 | start_time = time.time() 294 | R_gpu, cp_gpu = online_changepoint_detection( 295 | data_gpu.squeeze(), hazard_func, likelihood_gpu, device='cuda' 296 | ) 297 | torch.cuda.synchronize() 298 | gpu_time = time.time() - start_time 299 | print(f"GPU processing time: {gpu_time:.3f}s") 300 | 301 | # Compare results 302 | cp_gpu_cpu = cp_gpu.to('cpu') 303 | max_diff = torch.max(torch.abs(cp_cpu - cp_gpu_cpu)).item() 304 | print(f"Maximum difference between devices: {max_diff:.2e}") 305 | 306 | # Move results back to CPU for further processing 307 | print("\nMoving results back to CPU...") 308 | final_results = { 309 | 'R': R_gpu.to('cpu'), 310 | 'changepoint_probs': cp_gpu.to('cpu') 311 | } 312 | print(f"Results device: {final_results['R'].device}") 313 | 314 | else: 315 | print("\nGPU not available - staying on CPU") 316 | 317 | print() 318 | 319 | 320 | def main(): 321 | """Run GPU acceleration examples and benchmarks.""" 322 | print("GPU Acceleration Demo for Bayesian Changepoint Detection") 323 | print("=" * 65) 324 | 325 | # Display device information 326 | device = get_device() 327 | device_info = get_device_info() 328 | 329 | print(f"Default device: {device}") 330 | print(f"Available devices: {device_info['devices']}") 331 | print(f"CUDA available: {device_info['cuda_available']}") 332 | if device_info['cuda_available']: 333 | print(f"CUDA device count: {device_info['device_count']}") 334 | print(f"Current CUDA device: {device_info['current_device']}") 335 | print() 336 | 337 | # Run benchmarks and demos 338 | benchmark_univariate(data_length=1000, num_runs=3) 339 | benchmark_multivariate(dims=3, data_length=300, num_runs=3) 340 | 341 | memory_usage_demo() 342 | print() 343 | 344 | device_switching_demo() 345 | 346 | print("=" * 65) 347 | print("GPU acceleration demo completed!") 348 | print("=" * 65) 349 | 350 | 351 | if __name__ == "__main__": 352 | main() -------------------------------------------------------------------------------- /bayesian_changepoint_detection/online_likelihoods.py: -------------------------------------------------------------------------------- 1 | """ 2 | Online likelihood functions for Bayesian changepoint detection. 3 | 4 | This module provides likelihood functions for online (sequential) changepoint detection 5 | using PyTorch for efficient computation and GPU acceleration. 6 | """ 7 | 8 | import torch 9 | import torch.distributions as dist 10 | from abc import ABC, abstractmethod 11 | from typing import Union, Optional, Tuple 12 | from .device import ensure_tensor, get_device 13 | 14 | 15 | class BaseLikelihood(ABC): 16 | """ 17 | Abstract base class for online likelihood functions. 18 | 19 | This class provides a template for implementing likelihood functions 20 | for online Bayesian changepoint detection. Subclasses must implement 21 | the pdf and update_theta methods. 22 | 23 | Parameters 24 | ---------- 25 | device : str, torch.device, or None, optional 26 | Device to place tensors on (CPU or GPU). 27 | """ 28 | 29 | def __init__(self, device: Optional[Union[str, torch.device]] = None): 30 | self.device = get_device(device) 31 | self.t = 0 # Current time step 32 | 33 | @abstractmethod 34 | def pdf(self, data: torch.Tensor) -> torch.Tensor: 35 | """ 36 | Compute the probability density function for the observed data. 37 | 38 | Parameters 39 | ---------- 40 | data : torch.Tensor 41 | The data point to evaluate (shape: [1] for univariate, [D] for multivariate). 42 | 43 | Returns 44 | ------- 45 | torch.Tensor 46 | Log probability densities for all run lengths. 47 | """ 48 | raise NotImplementedError( 49 | "PDF method must be implemented in subclass." 50 | ) 51 | 52 | @abstractmethod 53 | def update_theta(self, data: torch.Tensor, **kwargs) -> None: 54 | """ 55 | Update the posterior parameters given new data. 56 | 57 | Parameters 58 | ---------- 59 | data : torch.Tensor 60 | The new data point to incorporate. 61 | **kwargs 62 | Additional arguments (e.g., timestep t). 63 | """ 64 | raise NotImplementedError( 65 | "update_theta method must be implemented in subclass." 66 | ) 67 | 68 | 69 | class StudentT(BaseLikelihood): 70 | """ 71 | Univariate Student's t-distribution likelihood for online changepoint detection. 72 | 73 | Uses a Normal-Gamma conjugate prior, resulting in a Student's t predictive 74 | distribution. This is suitable for univariate data with unknown mean and variance. 75 | 76 | Parameters 77 | ---------- 78 | alpha : float, optional 79 | Shape parameter of the Gamma prior on precision (default: 0.1). 80 | beta : float, optional 81 | Rate parameter of the Gamma prior on precision (default: 0.1). 82 | kappa : float, optional 83 | Precision parameter of the Normal prior on mean (default: 1.0). 84 | mu : float, optional 85 | Mean parameter of the Normal prior on mean (default: 0.0). 86 | device : str, torch.device, or None, optional 87 | Device to place tensors on. 88 | 89 | Examples 90 | -------- 91 | >>> import torch 92 | >>> likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 93 | >>> data = torch.tensor(1.5) 94 | >>> log_probs = likelihood.pdf(data) 95 | >>> likelihood.update_theta(data) 96 | 97 | Notes 98 | ----- 99 | The Student's t-distribution arises naturally as the predictive distribution 100 | when using Normal-Gamma conjugate priors for Gaussian data with unknown 101 | mean and variance. 102 | """ 103 | 104 | def __init__( 105 | self, 106 | alpha: float = 0.1, 107 | beta: float = 0.1, 108 | kappa: float = 1.0, 109 | mu: float = 0.0, 110 | device: Optional[Union[str, torch.device]] = None 111 | ): 112 | super().__init__(device) 113 | 114 | # Store initial hyperparameters 115 | self.alpha0 = alpha 116 | self.beta0 = beta 117 | self.kappa0 = kappa 118 | self.mu0 = mu 119 | 120 | # Initialize parameter vectors (will grow over time) 121 | self.alpha = torch.tensor([alpha], device=self.device, dtype=torch.float32) 122 | self.beta = torch.tensor([beta], device=self.device, dtype=torch.float32) 123 | self.kappa = torch.tensor([kappa], device=self.device, dtype=torch.float32) 124 | self.mu = torch.tensor([mu], device=self.device, dtype=torch.float32) 125 | 126 | def pdf(self, data: torch.Tensor) -> torch.Tensor: 127 | """ 128 | Compute log probability density under Student's t-distribution. 129 | 130 | Parameters 131 | ---------- 132 | data : torch.Tensor 133 | Scalar data point to evaluate. 134 | 135 | Returns 136 | ------- 137 | torch.Tensor 138 | Log probability densities for all current run lengths. 139 | """ 140 | data = ensure_tensor(data, device=self.device) 141 | if data.numel() != 1: 142 | raise ValueError("StudentT expects scalar input data") 143 | 144 | self.t += 1 145 | 146 | # Student's t-distribution parameters 147 | df = 2 * self.alpha 148 | loc = self.mu 149 | scale = torch.sqrt(self.beta * (self.kappa + 1) / (self.alpha * self.kappa)) 150 | 151 | # Compute log probabilities for all run lengths 152 | log_probs = torch.zeros(self.t, device=self.device, dtype=torch.float32) 153 | 154 | for i in range(self.t): 155 | t_dist = dist.StudentT(df=df[i], loc=loc[i], scale=scale[i]) 156 | log_probs[i] = t_dist.log_prob(data) 157 | 158 | return log_probs 159 | 160 | def update_theta(self, data: torch.Tensor, **kwargs) -> None: 161 | """ 162 | Update posterior parameters using conjugate prior updates. 163 | 164 | Parameters 165 | ---------- 166 | data : torch.Tensor 167 | New data point to incorporate. 168 | """ 169 | data = ensure_tensor(data, device=self.device) 170 | 171 | # Compute updated parameters 172 | mu_new = (self.kappa * self.mu + data) / (self.kappa + 1) 173 | kappa_new = self.kappa + 1.0 174 | alpha_new = self.alpha + 0.5 175 | beta_new = ( 176 | self.beta + 177 | (self.kappa * (data - self.mu) ** 2) / (2.0 * (self.kappa + 1.0)) 178 | ) 179 | 180 | # Concatenate with initial parameters to maintain history 181 | self.mu = torch.cat([ 182 | torch.tensor([self.mu0], device=self.device, dtype=torch.float32), 183 | mu_new 184 | ]) 185 | self.kappa = torch.cat([ 186 | torch.tensor([self.kappa0], device=self.device, dtype=torch.float32), 187 | kappa_new 188 | ]) 189 | self.alpha = torch.cat([ 190 | torch.tensor([self.alpha0], device=self.device, dtype=torch.float32), 191 | alpha_new 192 | ]) 193 | self.beta = torch.cat([ 194 | torch.tensor([self.beta0], device=self.device, dtype=torch.float32), 195 | beta_new 196 | ]) 197 | 198 | 199 | class MultivariateT(BaseLikelihood): 200 | """ 201 | Multivariate Student's t-distribution likelihood for online changepoint detection. 202 | 203 | Uses a Normal-Wishart conjugate prior, resulting in a multivariate Student's t 204 | predictive distribution. Suitable for multivariate data with unknown mean and covariance. 205 | 206 | Parameters 207 | ---------- 208 | dims : int 209 | Dimensionality of the data. 210 | dof : int, optional 211 | Initial degrees of freedom for Wishart prior (default: dims + 1). 212 | kappa : float, optional 213 | Precision parameter for Normal prior on mean (default: 1.0). 214 | mu : torch.Tensor or None, optional 215 | Prior mean vector (default: zero vector). 216 | scale : torch.Tensor or None, optional 217 | Prior scale matrix for Wishart distribution (default: identity matrix). 218 | device : str, torch.device, or None, optional 219 | Device to place tensors on. 220 | 221 | Examples 222 | -------- 223 | >>> import torch 224 | >>> likelihood = MultivariateT(dims=3) 225 | >>> data = torch.randn(3) 226 | >>> log_probs = likelihood.pdf(data) 227 | >>> likelihood.update_theta(data) 228 | 229 | Notes 230 | ----- 231 | The multivariate Student's t-distribution generalizes the univariate case 232 | to multiple dimensions, naturally handling correlations between variables. 233 | """ 234 | 235 | def __init__( 236 | self, 237 | dims: int, 238 | dof: Optional[int] = None, 239 | kappa: float = 1.0, 240 | mu: Optional[torch.Tensor] = None, 241 | scale: Optional[torch.Tensor] = None, 242 | device: Optional[Union[str, torch.device]] = None 243 | ): 244 | super().__init__(device) 245 | 246 | self.dims = dims 247 | 248 | # Set default parameters 249 | if dof is None: 250 | dof = dims + 1 251 | if mu is None: 252 | mu = torch.zeros(dims, device=self.device, dtype=torch.float32) 253 | else: 254 | mu = ensure_tensor(mu, device=self.device) 255 | if scale is None: 256 | scale = torch.eye(dims, device=self.device, dtype=torch.float32) 257 | else: 258 | scale = ensure_tensor(scale, device=self.device) 259 | 260 | # Store initial parameters 261 | self.dof0 = dof 262 | self.kappa0 = kappa 263 | self.mu0 = mu.clone() 264 | self.scale0 = scale.clone() 265 | 266 | # Initialize parameter arrays (will grow over time) 267 | self.dof = torch.tensor([dof], device=self.device, dtype=torch.float32) 268 | self.kappa = torch.tensor([kappa], device=self.device, dtype=torch.float32) 269 | self.mu = mu.unsqueeze(0) # Shape: [1, dims] 270 | self.scale = scale.unsqueeze(0) # Shape: [1, dims, dims] 271 | 272 | def pdf(self, data: torch.Tensor) -> torch.Tensor: 273 | """ 274 | Compute log probability density under multivariate Student's t-distribution. 275 | 276 | Parameters 277 | ---------- 278 | data : torch.Tensor 279 | Data vector to evaluate (shape: [dims]). 280 | 281 | Returns 282 | ------- 283 | torch.Tensor 284 | Log probability densities for all current run lengths. 285 | """ 286 | data = ensure_tensor(data, device=self.device) 287 | if data.shape != (self.dims,): 288 | raise ValueError(f"Expected data shape [{self.dims}], got {data.shape}") 289 | 290 | self.t += 1 291 | 292 | # Compute parameters for multivariate Student's t 293 | t_dof = self.dof - self.dims + 1 294 | scale_factor = (self.kappa * t_dof) / (self.kappa + 1) 295 | 296 | log_probs = torch.zeros(self.t, device=self.device, dtype=torch.float32) 297 | 298 | for i in range(self.t): 299 | # Compute precision matrix (inverse of scale matrix) with regularization 300 | scale_matrix = self.scale[i] / scale_factor[i] 301 | # Add small regularization to ensure positive definiteness 302 | reg_scale = scale_matrix + 1e-6 * torch.eye(self.dims, device=self.device, dtype=torch.float32) 303 | precision = torch.inverse(reg_scale) 304 | 305 | # Note: PyTorch doesn't have native multivariate t-distribution, 306 | # so we compute the log probability directly 307 | 308 | # Mahalanobis distance 309 | diff = data - self.mu[i] 310 | mahal_dist = torch.matmul(diff, torch.matmul(precision, diff)) 311 | 312 | # Multivariate t log-probability (manual computation) 313 | log_prob = ( 314 | torch.lgamma((t_dof[i] + self.dims) / 2) - 315 | torch.lgamma(t_dof[i] / 2) - 316 | (self.dims / 2) * torch.log(t_dof[i] * torch.pi) - 317 | 0.5 * torch.logdet(reg_scale) - 318 | ((t_dof[i] + self.dims) / 2) * torch.log(1 + mahal_dist / t_dof[i]) 319 | ) 320 | 321 | log_probs[i] = log_prob 322 | 323 | return log_probs 324 | 325 | def update_theta(self, data: torch.Tensor, **kwargs) -> None: 326 | """ 327 | Update posterior parameters using Normal-Wishart conjugate updates. 328 | 329 | Parameters 330 | ---------- 331 | data : torch.Tensor 332 | New data vector to incorporate. 333 | """ 334 | data = ensure_tensor(data, device=self.device) 335 | 336 | # Compute differences from current means 337 | centered = data.unsqueeze(0) - self.mu # Shape: [t, dims] 338 | 339 | # Update parameters using conjugate prior formulas 340 | mu_new = ( 341 | self.kappa.unsqueeze(1) * self.mu + data.unsqueeze(0) 342 | ) / (self.kappa + 1).unsqueeze(1) 343 | 344 | kappa_new = self.kappa + 1 345 | dof_new = self.dof + 1 346 | 347 | # Update scale matrices 348 | scale_update = ( 349 | self.kappa.unsqueeze(1).unsqueeze(2) / 350 | (self.kappa + 1).unsqueeze(1).unsqueeze(2) 351 | ) * torch.bmm(centered.unsqueeze(2), centered.unsqueeze(1)) 352 | 353 | # Regularized inverse to ensure numerical stability 354 | inv_scale = torch.inverse(self.scale + 1e-6 * torch.eye(self.dims, device=self.device, dtype=torch.float32).unsqueeze(0)) 355 | scale_new = torch.inverse( 356 | inv_scale + scale_update 357 | ) 358 | 359 | # Concatenate with initial parameters 360 | self.mu = torch.cat([self.mu0.unsqueeze(0), mu_new]) 361 | self.kappa = torch.cat([ 362 | torch.tensor([self.kappa0], device=self.device, dtype=torch.float32), 363 | kappa_new 364 | ]) 365 | self.dof = torch.cat([ 366 | torch.tensor([self.dof0], device=self.device, dtype=torch.float32), 367 | dof_new 368 | ]) 369 | self.scale = torch.cat([self.scale0.unsqueeze(0), scale_new]) -------------------------------------------------------------------------------- /bayesian_changepoint_detection/generate_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Data generation utilities for testing changepoint detection algorithms. 3 | 4 | This module provides functions to generate synthetic time series data with 5 | known changepoints for testing and benchmarking changepoint detection methods. 6 | """ 7 | 8 | import torch 9 | from typing import Tuple, Optional, Union 10 | from .device import get_device, ensure_tensor 11 | 12 | 13 | def generate_normal_time_series( 14 | num_segments: int, 15 | min_length: int = 50, 16 | max_length: int = 1000, 17 | seed: Optional[int] = 42, 18 | device: Optional[Union[str, torch.device]] = None 19 | ) -> Tuple[torch.Tensor, torch.Tensor]: 20 | """ 21 | Generate univariate time series with changepoints in mean and variance. 22 | 23 | Creates a time series consisting of multiple segments, each with different 24 | Gaussian parameters (mean and variance). 25 | 26 | Parameters 27 | ---------- 28 | num_segments : int 29 | Number of segments to generate. 30 | min_length : int, optional 31 | Minimum length of each segment (default: 50). 32 | max_length : int, optional 33 | Maximum length of each segment (default: 1000). 34 | seed : int or None, optional 35 | Random seed for reproducibility (default: 42). 36 | device : str, torch.device, or None, optional 37 | Device to place tensors on. 38 | 39 | Returns 40 | ------- 41 | partition : torch.Tensor 42 | Length of each segment. Shape: [num_segments]. 43 | data : torch.Tensor 44 | Generated time series data. Shape: [T, 1] where T is total length. 45 | 46 | Examples 47 | -------- 48 | >>> partition, data = generate_normal_time_series(3, 50, 200, seed=42) 49 | >>> print(f"Generated {len(data)} data points in {len(partition)} segments") 50 | >>> print(f"Segment lengths: {partition}") 51 | 52 | Notes 53 | ----- 54 | Each segment has: 55 | - Mean sampled from Normal(0, 10²) 56 | - Standard deviation sampled from |Normal(0, 1)| 57 | """ 58 | device = get_device(device) 59 | 60 | if seed is not None: 61 | torch.manual_seed(seed) 62 | 63 | # Generate segment lengths 64 | partition = torch.randint( 65 | min_length, max_length + 1, (num_segments,), 66 | device=device, dtype=torch.long 67 | ) 68 | 69 | # Generate data for each segment 70 | data_segments = [] 71 | 72 | for segment_length in partition: 73 | # Random mean and variance for this segment 74 | mean = torch.randn(1, device=device) * 10 75 | std = torch.abs(torch.randn(1, device=device)) + 0.1 # Ensure positive 76 | 77 | # Generate segment data 78 | segment_data = torch.normal( 79 | mean.expand(segment_length), 80 | std.expand(segment_length) 81 | ) 82 | data_segments.append(segment_data) 83 | 84 | # Concatenate all segments 85 | data = torch.cat(data_segments).unsqueeze(1) # Shape: [T, 1] 86 | 87 | return partition, data 88 | 89 | 90 | def generate_multivariate_normal_time_series( 91 | num_segments: int, 92 | dims: int, 93 | min_length: int = 50, 94 | max_length: int = 1000, 95 | seed: Optional[int] = 42, 96 | device: Optional[Union[str, torch.device]] = None 97 | ) -> Tuple[torch.Tensor, torch.Tensor]: 98 | """ 99 | Generate multivariate time series with changepoints in mean and covariance. 100 | 101 | Creates a multivariate time series with segments having different 102 | Gaussian parameters (mean vectors and covariance matrices). 103 | 104 | Parameters 105 | ---------- 106 | num_segments : int 107 | Number of segments to generate. 108 | dims : int 109 | Dimensionality of the time series. 110 | min_length : int, optional 111 | Minimum length of each segment (default: 50). 112 | max_length : int, optional 113 | Maximum length of each segment (default: 1000). 114 | seed : int or None, optional 115 | Random seed for reproducibility (default: 42). 116 | device : str, torch.device, or None, optional 117 | Device to place tensors on. 118 | 119 | Returns 120 | ------- 121 | partition : torch.Tensor 122 | Length of each segment. Shape: [num_segments]. 123 | data : torch.Tensor 124 | Generated time series data. Shape: [T, dims] where T is total length. 125 | 126 | Examples 127 | -------- 128 | >>> partition, data = generate_multivariate_normal_time_series(3, 5, seed=42) 129 | >>> print(f"Generated {data.shape[0]} time points with {data.shape[1]} dimensions") 130 | >>> print(f"Segment lengths: {partition}") 131 | 132 | Notes 133 | ----- 134 | Each segment has: 135 | - Mean vector sampled from Normal(0, 10²) for each dimension 136 | - Covariance matrix generated as A @ A.T where A ~ Normal(0, 1) 137 | """ 138 | device = get_device(device) 139 | 140 | if seed is not None: 141 | torch.manual_seed(seed) 142 | 143 | # Generate segment lengths 144 | partition = torch.randint( 145 | min_length, max_length + 1, (num_segments,), 146 | device=device, dtype=torch.long 147 | ) 148 | 149 | # Generate data for each segment 150 | data_segments = [] 151 | 152 | for segment_length in partition: 153 | # Random mean vector for this segment 154 | mean = torch.randn(dims, device=device) * 10 155 | 156 | # Generate random positive definite covariance matrix 157 | A = torch.randn(dims, dims, device=device) 158 | cov = torch.matmul(A, A.T) 159 | 160 | # Ensure numerical stability 161 | cov = cov + 1e-6 * torch.eye(dims, device=device) 162 | 163 | # Generate segment data using multivariate normal 164 | try: 165 | mvn = torch.distributions.MultivariateNormal(mean, cov) 166 | segment_data = mvn.sample((segment_length,)) 167 | except RuntimeError: 168 | # Fallback: use independent normals if covariance is problematic 169 | std = torch.sqrt(torch.diag(cov)) 170 | segment_data = torch.normal( 171 | mean.unsqueeze(0).expand(segment_length, -1), 172 | std.unsqueeze(0).expand(segment_length, -1) 173 | ) 174 | 175 | data_segments.append(segment_data) 176 | 177 | # Concatenate all segments 178 | data = torch.cat(data_segments, dim=0) # Shape: [T, dims] 179 | 180 | return partition, data 181 | 182 | 183 | def generate_correlation_change_example( 184 | min_length: int = 50, 185 | max_length: int = 1000, 186 | seed: Optional[int] = 42, 187 | device: Optional[Union[str, torch.device]] = None 188 | ) -> Tuple[torch.Tensor, torch.Tensor]: 189 | """ 190 | Generate the motivating example from Xiang & Murphy (2007). 191 | 192 | Creates a 2D time series with three segments that have the same mean 193 | but different correlation structures, demonstrating changepoints that 194 | are only detectable through covariance changes. 195 | 196 | Parameters 197 | ---------- 198 | min_length : int, optional 199 | Minimum length of each segment (default: 50). 200 | max_length : int, optional 201 | Maximum length of each segment (default: 1000). 202 | seed : int or None, optional 203 | Random seed for reproducibility (default: 42). 204 | device : str, torch.device, or None, optional 205 | Device to place tensors on. 206 | 207 | Returns 208 | ------- 209 | partition : torch.Tensor 210 | Length of each segment. Shape: [3]. 211 | data : torch.Tensor 212 | Generated time series data. Shape: [T, 2] where T is total length. 213 | 214 | Examples 215 | -------- 216 | >>> partition, data = generate_correlation_change_example(seed=42) 217 | >>> print(f"Generated correlation change example with segments: {partition}") 218 | 219 | Notes 220 | ----- 221 | The three segments have covariance matrices: 222 | 1. [[1.0, 0.75], [0.75, 1.0]] - Positive correlation 223 | 2. [[1.0, 0.0], [0.0, 1.0]] - No correlation 224 | 3. [[1.0, -0.75], [-0.75, 1.0]] - Negative correlation 225 | 226 | All segments have zero mean, so changepoints are only in correlation. 227 | 228 | References 229 | ---------- 230 | Xiang, X., & Murphy, K. (2007). Modeling changing dependency structure 231 | in multivariate time series. ICML, 1055-1062. 232 | """ 233 | device = get_device(device) 234 | 235 | if seed is not None: 236 | torch.manual_seed(seed) 237 | 238 | dims = 2 239 | num_segments = 3 240 | 241 | # Generate segment lengths 242 | partition = torch.randint( 243 | min_length, max_length + 1, (num_segments,), 244 | device=device, dtype=torch.long 245 | ) 246 | 247 | # Zero mean for all segments 248 | mu = torch.zeros(dims, device=device) 249 | 250 | # Define the three covariance matrices 251 | Sigma1 = torch.tensor([[1.0, 0.75], [0.75, 1.0]], device=device) # Positive correlation 252 | Sigma2 = torch.tensor([[1.0, 0.0], [0.0, 1.0]], device=device) # No correlation 253 | Sigma3 = torch.tensor([[1.0, -0.75], [-0.75, 1.0]], device=device) # Negative correlation 254 | 255 | covariances = [Sigma1, Sigma2, Sigma3] 256 | data_segments = [] 257 | 258 | for i, (segment_length, cov) in enumerate(zip(partition, covariances)): 259 | # Generate segment data 260 | mvn = torch.distributions.MultivariateNormal(mu, cov) 261 | segment_data = mvn.sample((segment_length,)) 262 | data_segments.append(segment_data) 263 | 264 | # Concatenate all segments 265 | data = torch.cat(data_segments, dim=0) # Shape: [T, 2] 266 | 267 | return partition, data 268 | 269 | 270 | def generate_mean_shift_example( 271 | num_segments: int = 4, 272 | segment_length: int = 100, 273 | shift_magnitude: float = 3.0, 274 | noise_std: float = 1.0, 275 | seed: Optional[int] = 42, 276 | device: Optional[Union[str, torch.device]] = None 277 | ) -> Tuple[torch.Tensor, torch.Tensor]: 278 | """ 279 | Generate time series with abrupt mean shifts. 280 | 281 | Creates a time series with segments of equal length but different means, 282 | useful for testing basic changepoint detection capabilities. 283 | 284 | Parameters 285 | ---------- 286 | num_segments : int, optional 287 | Number of segments (default: 4). 288 | segment_length : int, optional 289 | Length of each segment (default: 100). 290 | shift_magnitude : float, optional 291 | Magnitude of mean shifts between segments (default: 3.0). 292 | noise_std : float, optional 293 | Standard deviation of noise (default: 1.0). 294 | seed : int or None, optional 295 | Random seed for reproducibility (default: 42). 296 | device : str, torch.device, or None, optional 297 | Device to place tensors on. 298 | 299 | Returns 300 | ------- 301 | partition : torch.Tensor 302 | Length of each segment. Shape: [num_segments]. 303 | data : torch.Tensor 304 | Generated time series data. Shape: [T, 1] where T is total length. 305 | 306 | Examples 307 | -------- 308 | >>> partition, data = generate_mean_shift_example(4, 100, shift_magnitude=2.0) 309 | >>> print(f"Generated mean shift example: {len(data)} points, {len(partition)} segments") 310 | 311 | Notes 312 | ----- 313 | Means alternate between 0 and shift_magnitude, creating a step function 314 | pattern that should be easy to detect. 315 | """ 316 | device = get_device(device) 317 | 318 | if seed is not None: 319 | torch.manual_seed(seed) 320 | 321 | # All segments have the same length 322 | partition = torch.full((num_segments,), segment_length, device=device, dtype=torch.long) 323 | 324 | data_segments = [] 325 | 326 | for i in range(num_segments): 327 | # Alternate between 0 and shift_magnitude 328 | mean = (i % 2) * shift_magnitude 329 | 330 | # Generate segment data 331 | segment_data = torch.normal( 332 | mean, noise_std, (segment_length,), device=device 333 | ) 334 | data_segments.append(segment_data) 335 | 336 | # Concatenate all segments 337 | data = torch.cat(data_segments).unsqueeze(1) # Shape: [T, 1] 338 | 339 | return partition, data 340 | 341 | 342 | def generate_variance_change_example( 343 | num_segments: int = 3, 344 | segment_length: int = 150, 345 | variance_levels: Optional[torch.Tensor] = None, 346 | seed: Optional[int] = 42, 347 | device: Optional[Union[str, torch.device]] = None 348 | ) -> Tuple[torch.Tensor, torch.Tensor]: 349 | """ 350 | Generate time series with variance changes but constant mean. 351 | 352 | Creates segments with the same mean but different variances, 353 | testing the ability to detect heteroscedastic changepoints. 354 | 355 | Parameters 356 | ---------- 357 | num_segments : int, optional 358 | Number of segments (default: 3). 359 | segment_length : int, optional 360 | Length of each segment (default: 150). 361 | variance_levels : torch.Tensor or None, optional 362 | Variance levels for each segment. If None, uses [0.5, 2.0, 0.8]. 363 | seed : int or None, optional 364 | Random seed for reproducibility (default: 42). 365 | device : str, torch.device, or None, optional 366 | Device to place tensors on. 367 | 368 | Returns 369 | ------- 370 | partition : torch.Tensor 371 | Length of each segment. Shape: [num_segments]. 372 | data : torch.Tensor 373 | Generated time series data. Shape: [T, 1] where T is total length. 374 | 375 | Examples 376 | -------- 377 | >>> partition, data = generate_variance_change_example(3, 100) 378 | >>> print(f"Generated variance change example with {len(data)} points") 379 | 380 | Notes 381 | ----- 382 | All segments have zero mean, so changepoints are only detectable through 383 | variance changes. This tests the algorithm's sensitivity to second-moment changes. 384 | """ 385 | device = get_device(device) 386 | 387 | if seed is not None: 388 | torch.manual_seed(seed) 389 | 390 | if variance_levels is None: 391 | variance_levels = torch.tensor([0.5, 2.0, 0.8], device=device) 392 | else: 393 | variance_levels = ensure_tensor(variance_levels, device=device) 394 | 395 | if len(variance_levels) != num_segments: 396 | raise ValueError(f"Number of variance levels ({len(variance_levels)}) must match num_segments ({num_segments})") 397 | 398 | # All segments have the same length 399 | partition = torch.full((num_segments,), segment_length, device=device, dtype=torch.long) 400 | 401 | data_segments = [] 402 | 403 | for i, variance in enumerate(variance_levels): 404 | # Zero mean, different variance 405 | std = torch.sqrt(variance) 406 | segment_data = torch.normal( 407 | 0.0, std.item(), (segment_length,), device=device 408 | ) 409 | data_segments.append(segment_data) 410 | 411 | # Concatenate all segments 412 | data = torch.cat(data_segments).unsqueeze(1) # Shape: [T, 1] 413 | 414 | return partition, data 415 | 416 | 417 | # Backward compatibility with original function names 418 | def generate_multinormal_time_series(*args, **kwargs): 419 | """Backward compatibility wrapper for generate_multivariate_normal_time_series.""" 420 | return generate_multivariate_normal_time_series(*args, **kwargs) 421 | 422 | 423 | def generate_xuan_motivating_example(*args, **kwargs): 424 | """Backward compatibility wrapper for generate_correlation_change_example.""" 425 | return generate_correlation_change_example(*args, **kwargs) -------------------------------------------------------------------------------- /docs/gpu_online_detection_guide.md: -------------------------------------------------------------------------------- 1 | # GPU Acceleration Guide 2 | 3 | This guide provides comprehensive examples for using GPU acceleration with the Bayesian Changepoint Detection library. 4 | 5 | ## Table of Contents 6 | 7 | 1. [Prerequisites](#prerequisites) 8 | 2. [Setup and Verification](#setup-and-verification) 9 | 3. [Basic GPU Example](#basic-gpu-example) 10 | 4. [Performance Comparison](#performance-comparison) 11 | 5. [Multivariate Detection](#multivariate-detection) 12 | 6. [Offline Detection](#offline-detection) 13 | 7. [Visualization](#visualization) 14 | 8. [Memory Management](#memory-management) 15 | 9. [Best Practices](#best-practices) 16 | 17 | ## Prerequisites 18 | 19 | Before running GPU-accelerated changepoint detection, ensure you have: 20 | 21 | - NVIDIA GPU with CUDA support 22 | - CUDA drivers installed 23 | - PyTorch with CUDA support installed 24 | - Sufficient GPU memory for your dataset 25 | 26 | ## Setup and Verification 27 | 28 | First, verify your CUDA setup and import the necessary libraries: 29 | 30 | ```python 31 | import torch 32 | import numpy as np 33 | import matplotlib.pyplot as plt 34 | from functools import partial 35 | 36 | # First, verify CUDA is available 37 | print(f"CUDA available: {torch.cuda.is_available()}") 38 | if torch.cuda.is_available(): 39 | print(f"CUDA device count: {torch.cuda.device_count()}") 40 | print(f"CUDA device name: {torch.cuda.get_device_name(0)}") 41 | print(f"CUDA memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB") 42 | 43 | # Import our library 44 | from bayesian_changepoint_detection import ( 45 | online_changepoint_detection, 46 | offline_changepoint_detection, 47 | constant_hazard, 48 | const_prior, 49 | get_device, 50 | get_device_info 51 | ) 52 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 53 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 54 | ``` 55 | 56 | ## Basic GPU Example 57 | 58 | Here's a simple example to get started with GPU acceleration: 59 | 60 | ```python 61 | import torch 62 | from functools import partial 63 | from bayesian_changepoint_detection import online_changepoint_detection, constant_hazard 64 | from bayesian_changepoint_detection.online_likelihoods import StudentT 65 | 66 | # Generate sample data 67 | torch.manual_seed(42) 68 | data = torch.cat([ 69 | torch.randn(100) + 0, # First segment: mean=0 70 | torch.randn(100) + 3, # Second segment: mean=3 71 | torch.randn(100) + 0, # Third segment: mean=0 72 | ]) 73 | 74 | # Set device (automatically selects GPU if available) 75 | device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') 76 | print(f"Using device: {device}") 77 | 78 | # Move data to GPU 79 | data_gpu = data.to(device) 80 | 81 | # Set up the model on GPU 82 | hazard_func = partial(constant_hazard, 250) 83 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device=device) 84 | 85 | # Run detection on GPU 86 | run_length_probs, changepoint_probs = online_changepoint_detection( 87 | data_gpu, hazard_func, likelihood 88 | ) 89 | 90 | # Find changepoints (threshold at 0.5) 91 | detected = torch.where(changepoint_probs > 0.5)[0].cpu().numpy() 92 | print(f"Detected changepoints at: {detected}") 93 | ``` 94 | 95 | ## Generate Test Data 96 | 97 | For comprehensive testing, let's create more complex test data: 98 | 99 | ```python 100 | # Set random seed for reproducibility 101 | torch.manual_seed(42) 102 | np.random.seed(42) 103 | 104 | # Generate univariate data with multiple changepoints 105 | n_segments = 5 106 | segment_length = 200 107 | total_length = n_segments * segment_length 108 | 109 | # Create data with different means and variances 110 | segments = [] 111 | true_changepoints = [] 112 | 113 | for i in range(n_segments): 114 | # Varying means and standard deviations 115 | mean = (-1) ** i * (i + 1) * 2 # Alternating means: -2, 4, -6, 8, -10 116 | std = 0.5 + i * 0.3 # Increasing variance: 0.5, 0.8, 1.1, 1.4, 1.7 117 | 118 | segment = torch.randn(segment_length) * std + mean 119 | segments.append(segment) 120 | 121 | if i > 0: # Don't include the start as a changepoint 122 | true_changepoints.append(i * segment_length) 123 | 124 | # Combine segments 125 | data = torch.cat(segments) 126 | print(f"Generated data shape: {data.shape}") 127 | print(f"True changepoints at: {true_changepoints}") 128 | 129 | # Generate multivariate data for comparison 130 | dims = 3 131 | mv_segments = [] 132 | for i in range(n_segments): 133 | mean_vector = torch.tensor([(-1)**i * (i+1), i*0.5, (-1)**(i+1) * i]) 134 | cov_scale = 0.5 + i * 0.2 135 | segment = torch.randn(segment_length, dims) * cov_scale + mean_vector 136 | mv_segments.append(segment) 137 | 138 | mv_data = torch.cat(mv_segments) 139 | print(f"Generated multivariate data shape: {mv_data.shape}") 140 | ``` 141 | 142 | ## Performance Comparison 143 | 144 | Compare CPU vs GPU performance: 145 | 146 | ```python 147 | import time 148 | 149 | def benchmark_detection(data, device_name='cpu', n_runs=3): 150 | """Benchmark changepoint detection on specified device.""" 151 | device = torch.device(device_name) 152 | 153 | # Move data to device 154 | data_device = data.to(device) 155 | 156 | # Setup models 157 | hazard_func = partial(constant_hazard, 250) 158 | online_likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device=device) 159 | 160 | times = [] 161 | 162 | for run in range(n_runs): 163 | torch.cuda.synchronize() if device.type == 'cuda' else None 164 | start_time = time.time() 165 | 166 | # Run online detection 167 | run_length_probs, changepoint_probs = online_changepoint_detection( 168 | data_device, hazard_func, online_likelihood 169 | ) 170 | 171 | torch.cuda.synchronize() if device.type == 'cuda' else None 172 | end_time = time.time() 173 | 174 | times.append(end_time - start_time) 175 | 176 | avg_time = np.mean(times) 177 | std_time = np.std(times) 178 | 179 | return avg_time, std_time, changepoint_probs 180 | 181 | # Benchmark on CPU 182 | print("Benchmarking on CPU...") 183 | cpu_time, cpu_std, cpu_results = benchmark_detection(data, 'cpu') 184 | print(f"CPU time: {cpu_time:.3f} ± {cpu_std:.3f} seconds") 185 | 186 | # Benchmark on GPU (if available) 187 | if torch.cuda.is_available(): 188 | print("Benchmarking on GPU...") 189 | gpu_time, gpu_std, gpu_results = benchmark_detection(data, 'cuda') 190 | print(f"GPU time: {gpu_time:.3f} ± {gpu_std:.3f} seconds") 191 | speedup = cpu_time / gpu_time 192 | print(f"GPU speedup: {speedup:.1f}x") 193 | else: 194 | print("GPU not available, skipping GPU benchmark") 195 | gpu_results = cpu_results 196 | ``` 197 | 198 | ## Multivariate Detection 199 | 200 | GPU acceleration is especially beneficial for multivariate data: 201 | 202 | ```python 203 | # Multivariate detection on GPU 204 | device = get_device() # Automatically selects best device 205 | print(f"Using device: {device}") 206 | 207 | # Move multivariate data to device 208 | mv_data_device = mv_data.to(device) 209 | 210 | # Setup multivariate model 211 | hazard_func = partial(constant_hazard, 250) 212 | mv_likelihood = MultivariateT(dims=dims, device=device) 213 | 214 | print("Running multivariate changepoint detection...") 215 | start_time = time.time() 216 | 217 | mv_run_length_probs, mv_changepoint_probs = online_changepoint_detection( 218 | mv_data_device, hazard_func, mv_likelihood 219 | ) 220 | 221 | end_time = time.time() 222 | print(f"Multivariate detection time: {end_time - start_time:.3f} seconds") 223 | 224 | # Find detected changepoints (threshold at 0.5) 225 | detected_changepoints = torch.where(mv_changepoint_probs > 0.5)[0].cpu().numpy() 226 | print(f"Detected changepoints: {detected_changepoints}") 227 | ``` 228 | 229 | ## Offline Detection 230 | 231 | GPU acceleration also works with offline detection methods: 232 | 233 | ```python 234 | # Offline detection for comparison 235 | print("Running offline changepoint detection...") 236 | 237 | # Setup offline model 238 | prior_func = partial(const_prior, p=1/(len(data)+1)) 239 | offline_likelihood = OfflineStudentT(device=device) 240 | 241 | # Move data to device for offline detection 242 | data_device = data.to(device) 243 | 244 | start_time = time.time() 245 | Q, P, changepoint_log_probs = offline_changepoint_detection( 246 | data_device, prior_func, offline_likelihood 247 | ) 248 | end_time = time.time() 249 | 250 | print(f"Offline detection time: {end_time - start_time:.3f} seconds") 251 | 252 | # Get changepoint probabilities 253 | offline_changepoint_probs = torch.exp(changepoint_log_probs).sum(0) 254 | offline_detected = torch.where(offline_changepoint_probs > 0.5)[0].cpu().numpy() 255 | print(f"Offline detected changepoints: {offline_detected}") 256 | ``` 257 | 258 | ## Visualization 259 | 260 | Create comprehensive plots to visualize results: 261 | 262 | ```python 263 | # Plot results (convert back to CPU for plotting) 264 | fig, axes = plt.subplots(3, 1, figsize=(12, 10)) 265 | 266 | # Plot 1: Original data with true changepoints 267 | axes[0].plot(data.cpu().numpy()) 268 | for cp in true_changepoints: 269 | axes[0].axvline(cp, color='red', linestyle='--', alpha=0.7, 270 | label='True changepoint' if cp == true_changepoints[0] else "") 271 | axes[0].set_title('Original Time Series Data') 272 | axes[0].set_ylabel('Value') 273 | axes[0].legend() 274 | axes[0].grid(True, alpha=0.3) 275 | 276 | # Plot 2: Online detection probabilities 277 | axes[1].plot(cpu_results.cpu().numpy(), label='Online (CPU)', alpha=0.8) 278 | if torch.cuda.is_available(): 279 | axes[1].plot(gpu_results.cpu().numpy(), label='Online (GPU)', alpha=0.8, linestyle=':') 280 | axes[1].axhline(0.5, color='black', linestyle='-', alpha=0.5, label='Threshold') 281 | for cp in true_changepoints: 282 | axes[1].axvline(cp, color='red', linestyle='--', alpha=0.7) 283 | axes[1].set_title('Online Changepoint Detection Probabilities') 284 | axes[1].set_ylabel('Probability') 285 | axes[1].legend() 286 | axes[1].grid(True, alpha=0.3) 287 | 288 | # Plot 3: Multivariate detection probabilities 289 | axes[2].plot(mv_changepoint_probs.cpu().numpy(), label='Multivariate', color='green') 290 | axes[2].axhline(0.5, color='black', linestyle='-', alpha=0.5, label='Threshold') 291 | for cp in true_changepoints: 292 | axes[2].axvline(cp, color='red', linestyle='--', alpha=0.7) 293 | axes[2].set_title('Multivariate Changepoint Detection Probabilities') 294 | axes[2].set_xlabel('Time') 295 | axes[2].set_ylabel('Probability') 296 | axes[2].legend() 297 | axes[2].grid(True, alpha=0.3) 298 | 299 | plt.tight_layout() 300 | plt.savefig('gpu_changepoint_detection_example.png', dpi=150, bbox_inches='tight') 301 | plt.show() 302 | 303 | print("Example completed! Plot saved as 'gpu_changepoint_detection_example.png'") 304 | ``` 305 | 306 | ## Memory Management 307 | 308 | For large datasets, proper memory management is crucial: 309 | 310 | ```python 311 | # Monitor GPU memory usage 312 | if torch.cuda.is_available(): 313 | print(f"GPU memory allocated: {torch.cuda.memory_allocated() / 1e6:.1f} MB") 314 | print(f"GPU memory cached: {torch.cuda.memory_reserved() / 1e6:.1f} MB") 315 | 316 | # Clear cache if needed 317 | torch.cuda.empty_cache() 318 | print("GPU memory cache cleared") 319 | 320 | # For extremely large datasets, consider processing in chunks 321 | def chunked_detection(data, chunk_size=10000): 322 | """Process large datasets in chunks to manage memory.""" 323 | n_chunks = len(data) // chunk_size + (1 if len(data) % chunk_size > 0 else 0) 324 | 325 | all_probs = [] 326 | for i in range(n_chunks): 327 | start_idx = i * chunk_size 328 | end_idx = min((i + 1) * chunk_size, len(data)) 329 | chunk = data[start_idx:end_idx] 330 | 331 | # Process chunk 332 | _, chunk_probs = online_changepoint_detection(chunk, hazard_func, online_likelihood) 333 | all_probs.append(chunk_probs.cpu()) # Move to CPU to save GPU memory 334 | 335 | # Clear GPU cache between chunks 336 | torch.cuda.empty_cache() 337 | 338 | return torch.cat(all_probs) 339 | 340 | print("Example of chunked processing for large datasets completed") 341 | ``` 342 | 343 | ## Best Practices 344 | 345 | ### When to Use GPU Acceleration 346 | 347 | GPU acceleration is most beneficial for: 348 | 349 | - **Large datasets** (>1000 time points) 350 | - **Multivariate data** (multiple dimensions) 351 | - **Multiple runs** or hyperparameter tuning 352 | - **Real-time applications** requiring low latency 353 | - **Batch processing** of multiple time series 354 | 355 | ### Memory Optimization Tips 356 | 357 | 1. **Use appropriate data types**: Float32 instead of Float64 when precision allows 358 | 2. **Clear cache regularly**: Use `torch.cuda.empty_cache()` between operations 359 | 3. **Process in chunks**: For very large datasets, process data in smaller chunks 360 | 4. **Monitor memory usage**: Use `torch.cuda.memory_allocated()` to track usage 361 | 362 | ### Performance Tips 363 | 364 | 1. **Minimize CPU-GPU transfers**: Keep data on GPU throughout the pipeline 365 | 2. **Use batch operations**: Process multiple time series simultaneously 366 | 3. **Warm up the GPU**: Run a small example first to initialize CUDA kernels 367 | 4. **Profile your code**: Use PyTorch profiler to identify bottlenecks 368 | 369 | ### Device Selection 370 | 371 | ```python 372 | # Automatic device selection (recommended) 373 | device = get_device() 374 | 375 | # Manual device selection 376 | device = torch.device('cuda:0') # Specific GPU 377 | device = torch.device('cpu') # Force CPU 378 | 379 | # Check device capabilities 380 | if torch.cuda.is_available(): 381 | print(f"GPU memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB") 382 | print(f"GPU compute capability: {torch.cuda.get_device_capability(0)}") 383 | ``` 384 | 385 | ### Error Handling 386 | 387 | ```python 388 | try: 389 | # GPU computation 390 | data_gpu = data.to('cuda') 391 | result = online_changepoint_detection(data_gpu, hazard_func, likelihood) 392 | except RuntimeError as e: 393 | if "out of memory" in str(e): 394 | print("GPU out of memory, falling back to CPU") 395 | torch.cuda.empty_cache() 396 | data_cpu = data.to('cpu') 397 | likelihood_cpu = StudentT(device='cpu') 398 | result = online_changepoint_detection(data_cpu, hazard_func, likelihood_cpu) 399 | else: 400 | raise e 401 | ``` 402 | 403 | ## Complete Example Script 404 | 405 | Here's a complete script that demonstrates all the concepts above: 406 | 407 | ```python 408 | #!/usr/bin/env python3 409 | """ 410 | Complete GPU acceleration example for Bayesian changepoint detection. 411 | """ 412 | 413 | import torch 414 | import numpy as np 415 | import matplotlib.pyplot as plt 416 | import time 417 | from functools import partial 418 | 419 | # Import the library 420 | from bayesian_changepoint_detection import ( 421 | online_changepoint_detection, 422 | offline_changepoint_detection, 423 | constant_hazard, 424 | const_prior, 425 | get_device, 426 | get_device_info 427 | ) 428 | from bayesian_changepoint_detection.online_likelihoods import StudentT, MultivariateT 429 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 430 | 431 | def main(): 432 | # Check device availability 433 | print("=== Device Information ===") 434 | print(get_device_info()) 435 | 436 | device = get_device() 437 | print(f"Selected device: {device}") 438 | 439 | # Generate test data 440 | print("\n=== Generating Test Data ===") 441 | torch.manual_seed(42) 442 | 443 | # Simple univariate data 444 | data = torch.cat([ 445 | torch.randn(200) + 0, 446 | torch.randn(200) + 3, 447 | torch.randn(200) + 0, 448 | torch.randn(200) + -2, 449 | torch.randn(200) + 1, 450 | ]) 451 | 452 | print(f"Generated data shape: {data.shape}") 453 | 454 | # Move to device 455 | data_device = data.to(device) 456 | 457 | # Setup models 458 | hazard_func = partial(constant_hazard, 250) 459 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0, device=device) 460 | 461 | # Run detection 462 | print("\n=== Running Changepoint Detection ===") 463 | start_time = time.time() 464 | 465 | run_length_probs, changepoint_probs = online_changepoint_detection( 466 | data_device, hazard_func, likelihood 467 | ) 468 | 469 | end_time = time.time() 470 | print(f"Detection completed in {end_time - start_time:.3f} seconds") 471 | 472 | # Find changepoints 473 | detected = torch.where(changepoint_probs > 0.5)[0].cpu().numpy() 474 | print(f"Detected changepoints at: {detected}") 475 | 476 | # Plot results 477 | plt.figure(figsize=(12, 8)) 478 | 479 | plt.subplot(2, 1, 1) 480 | plt.plot(data.cpu().numpy()) 481 | plt.title('Time Series Data') 482 | plt.ylabel('Value') 483 | plt.grid(True, alpha=0.3) 484 | 485 | plt.subplot(2, 1, 2) 486 | plt.plot(changepoint_probs.cpu().numpy()) 487 | plt.axhline(0.5, color='red', linestyle='--', alpha=0.7, label='Threshold') 488 | plt.title('Changepoint Probabilities') 489 | plt.xlabel('Time') 490 | plt.ylabel('Probability') 491 | plt.legend() 492 | plt.grid(True, alpha=0.3) 493 | 494 | plt.tight_layout() 495 | plt.savefig('gpu_example_result.png', dpi=150, bbox_inches='tight') 496 | plt.show() 497 | 498 | print("\nExample completed successfully!") 499 | print("Result plot saved as 'gpu_example_result.png'") 500 | 501 | if __name__ == "__main__": 502 | main() 503 | ``` 504 | 505 | This guide provides everything you need to effectively use GPU acceleration with the Bayesian Changepoint Detection library. For more examples, see the `examples/` directory in the repository. -------------------------------------------------------------------------------- /bayesian_changepoint_detection/bayesian_models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core Bayesian changepoint detection algorithms. 3 | 4 | This module implements both online and offline Bayesian changepoint detection 5 | algorithms using PyTorch for efficient computation and GPU acceleration. 6 | """ 7 | 8 | import torch 9 | from typing import Union, Callable, Tuple, Optional 10 | from .device import ensure_tensor, get_device 11 | from .online_likelihoods import BaseLikelihood as OnlineLikelihood 12 | from .offline_likelihoods import BaseLikelihood as OfflineLikelihood 13 | 14 | 15 | def offline_changepoint_detection( 16 | data: torch.Tensor, 17 | prior_function: Callable[[int], float], 18 | likelihood_model: OfflineLikelihood, 19 | truncate: float = -40.0, 20 | device: Optional[Union[str, torch.device]] = None 21 | ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: 22 | """ 23 | Offline Bayesian changepoint detection using dynamic programming. 24 | 25 | Computes the exact posterior distribution over changepoint locations 26 | using the algorithm described in Fearnhead (2006). 27 | 28 | Parameters 29 | ---------- 30 | data : torch.Tensor 31 | Time series data of shape [T] or [T, D] where T is time and D is dimensions. 32 | prior_function : callable 33 | Function that returns log prior probability for a segment of given length. 34 | Should take an integer (segment length) and return a float (log probability). 35 | likelihood_model : OfflineLikelihood 36 | Likelihood model for computing segment probabilities. 37 | truncate : float, optional 38 | Log probability threshold for truncating computation (default: -40.0). 39 | More negative values = more accurate but slower computation. 40 | device : str, torch.device, or None, optional 41 | Device to place tensors on. 42 | 43 | Returns 44 | ------- 45 | Q : torch.Tensor 46 | Log evidence for data[t:] for each time t. Shape: [T]. 47 | P : torch.Tensor 48 | Log likelihood of segment [t, s] with no changepoints. Shape: [T, T]. 49 | Pcp : torch.Tensor 50 | Log probability of j-th changepoint at time t. Shape: [T-1, T-1]. 51 | 52 | Examples 53 | -------- 54 | >>> import torch 55 | >>> from functools import partial 56 | >>> from bayesian_changepoint_detection import ( 57 | ... offline_changepoint_detection, const_prior, StudentT 58 | ... ) 59 | >>> 60 | >>> data = torch.randn(100) 61 | >>> prior_func = partial(const_prior, p=0.01) 62 | >>> likelihood = StudentT() 63 | >>> Q, P, Pcp = offline_changepoint_detection(data, prior_func, likelihood) 64 | >>> 65 | >>> # Get changepoint probabilities 66 | >>> changepoint_probs = torch.exp(Pcp).sum(0) 67 | >>> detected_changepoints = torch.where(changepoint_probs > 0.5)[0] 68 | 69 | Notes 70 | ----- 71 | This algorithm has O(T^2) time complexity in the worst case, but the truncation 72 | parameter can make it approximately O(T) for most practical cases. 73 | 74 | References 75 | ---------- 76 | Fearnhead, P. (2006). Exact and efficient Bayesian inference for multiple 77 | changepoint problems. Statistics and Computing, 16(2), 203-213. 78 | """ 79 | device = get_device(device) 80 | data = ensure_tensor(data, device=device) 81 | 82 | if data.dim() == 1: 83 | n = data.shape[0] 84 | else: 85 | n = data.shape[0] # First dimension is time 86 | 87 | # Initialize arrays 88 | Q = torch.zeros(n, device=device, dtype=torch.float32) 89 | g = torch.zeros(n, device=device, dtype=torch.float32) 90 | G = torch.zeros(n, device=device, dtype=torch.float32) 91 | P = torch.full((n, n), float('-inf'), device=device, dtype=torch.float32) 92 | 93 | # Compute prior probabilities in log space 94 | for t in range(n): 95 | g[t] = prior_function(t) 96 | if t == 0: 97 | G[t] = g[t] 98 | else: 99 | G[t] = torch.logaddexp(G[t - 1], g[t]) 100 | 101 | # Initialize the last time point 102 | P[n - 1, n - 1] = likelihood_model.pdf(data, n - 1, n) 103 | Q[n - 1] = P[n - 1, n - 1] 104 | 105 | # Dynamic programming: work backwards through time 106 | for t in reversed(range(n - 1)): 107 | P_next_cp = torch.tensor(float('-inf'), device=device) # log(0) 108 | 109 | for s in range(t, n - 1): 110 | # Compute likelihood for segment [t, s+1] 111 | P[t, s] = likelihood_model.pdf(data, t, s + 1) 112 | 113 | # Compute recursion for changepoint probability 114 | summand = P[t, s] + Q[s + 1] + g[s + 1 - t] 115 | P_next_cp = torch.logaddexp(P_next_cp, summand) 116 | 117 | # Truncate sum for computational efficiency (Fearnhead 2006, eq. 3) 118 | if summand - P_next_cp < truncate: 119 | break 120 | 121 | # Compute likelihood for segment from t to end 122 | P[t, n - 1] = likelihood_model.pdf(data, t, n) 123 | 124 | # Compute (1 - G) in numerically stable way 125 | if G[n - 1 - t] < -1e-15: # exp(-1e-15) ≈ 0.99999... 126 | antiG = torch.log(1 - torch.exp(G[n - 1 - t])) 127 | else: 128 | # For G close to 1, use approximation (1 - G) ≈ -log(G) 129 | antiG = torch.log(-G[n - 1 - t]) 130 | 131 | # Combine changepoint and no-changepoint probabilities 132 | Q[t] = torch.logaddexp(P_next_cp, P[t, n - 1] + antiG) 133 | 134 | # Compute changepoint probability matrix 135 | Pcp = torch.full((n - 1, n - 1), float('-inf'), device=device, dtype=torch.float32) 136 | 137 | # First changepoint probabilities 138 | for t in range(n - 1): 139 | Pcp[0, t] = P[0, t] + Q[t + 1] + g[t] - Q[0] 140 | if torch.isnan(Pcp[0, t]): 141 | Pcp[0, t] = float('-inf') 142 | 143 | # Subsequent changepoint probabilities 144 | for j in range(1, n - 1): 145 | for t in range(j, n - 1): 146 | # Compute conditional probability for j-th changepoint at time t 147 | tmp_cond = ( 148 | Pcp[j - 1, j - 1:t] + 149 | P[j:t + 1, t] + 150 | Q[t + 1] + 151 | g[0:t - j + 1] - 152 | Q[j:t + 1] 153 | ) 154 | Pcp[j, t] = torch.logsumexp(tmp_cond, dim=0) 155 | if torch.isnan(Pcp[j, t]): 156 | Pcp[j, t] = float('-inf') 157 | 158 | return Q, P, Pcp 159 | 160 | 161 | def online_changepoint_detection( 162 | data: torch.Tensor, 163 | hazard_function: Callable[[torch.Tensor], torch.Tensor], 164 | likelihood_model: OnlineLikelihood, 165 | device: Optional[Union[str, torch.device]] = None 166 | ) -> Tuple[torch.Tensor, torch.Tensor]: 167 | """ 168 | Online Bayesian changepoint detection with run length filtering. 169 | 170 | Processes data sequentially, maintaining a posterior distribution over 171 | run lengths (time since last changepoint) as described in Adams & MacKay (2007). 172 | 173 | Parameters 174 | ---------- 175 | data : torch.Tensor 176 | Time series data of shape [T] or [T, D] where T is time and D is dimensions. 177 | hazard_function : callable 178 | Function that takes run length tensor and returns hazard probabilities. 179 | Should accept torch.Tensor of run lengths and return torch.Tensor of same shape. 180 | likelihood_model : OnlineLikelihood 181 | Online likelihood model that maintains sufficient statistics. 182 | device : str, torch.device, or None, optional 183 | Device to place tensors on. 184 | 185 | Returns 186 | ------- 187 | R : torch.Tensor 188 | Run length probability matrix. R[r, t] is the probability at time t 189 | that the current run length is r. Shape: [T+1, T+1]. 190 | changepoint_probs : torch.Tensor 191 | Probability of changepoint at each time step. Shape: [T+1]. 192 | 193 | Examples 194 | -------- 195 | >>> import torch 196 | >>> from functools import partial 197 | >>> from bayesian_changepoint_detection import ( 198 | ... online_changepoint_detection, constant_hazard, StudentT 199 | ... ) 200 | >>> 201 | >>> data = torch.randn(100) 202 | >>> hazard_func = partial(constant_hazard, 250) # Expected run length = 250 203 | >>> likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 204 | >>> R, changepoint_probs = online_changepoint_detection(data, hazard_func, likelihood) 205 | >>> 206 | >>> # Detect changepoints with probability > 0.5 207 | >>> detected = torch.where(changepoint_probs > 0.5)[0] 208 | >>> print(f"Changepoints detected at: {detected}") 209 | 210 | Notes 211 | ----- 212 | This algorithm has O(T^2) time complexity but is naturally online and can 213 | process streaming data. The run length distribution is normalized at each 214 | step for numerical stability. 215 | 216 | References 217 | ---------- 218 | Adams, R. P., & MacKay, D. J. (2007). Bayesian online changepoint detection. 219 | arXiv preprint arXiv:0710.3742. 220 | """ 221 | device = get_device(device) 222 | data = ensure_tensor(data, device=device) 223 | 224 | if data.dim() == 1: 225 | T = data.shape[0] 226 | else: 227 | T = data.shape[0] # First dimension is time 228 | 229 | # Initialize run length probability matrix 230 | R = torch.zeros(T + 1, T + 1, device=device, dtype=torch.float32) 231 | R[0, 0] = 1.0 # Initially, run length is 0 with probability 1 232 | 233 | # Track changepoint probabilities (probability of changepoint at each time) 234 | changepoint_probs = torch.zeros(T + 1, device=device, dtype=torch.float32) 235 | changepoint_probs[0] = 1.0 # Changepoint at time 0 by definition 236 | 237 | # Process each data point sequentially 238 | for t in range(T): 239 | # Get current data point 240 | if data.dim() == 1: 241 | x = data[t] 242 | else: 243 | x = data[t] 244 | 245 | # Evaluate predictive probabilities under current parameters 246 | # This gives us p(x_t | x_{1:t-1}, r_{t-1}) for all possible run lengths 247 | pred_log_probs = likelihood_model.pdf(x) 248 | 249 | # Convert to probabilities (but keep in log space for stability) 250 | pred_probs = torch.exp(pred_log_probs) 251 | 252 | # Evaluate hazard function for current run lengths 253 | run_lengths = torch.arange(t + 1, device=device, dtype=torch.float32) 254 | H = hazard_function(run_lengths) 255 | 256 | # Growth probabilities: shift probabilities down and right, 257 | # scaled by hazard function and predictive probabilities 258 | # R[r+1, t+1] = R[r, t] * p(x_t | r) * (1 - H(r)) 259 | R[1:t + 2, t + 1] = R[0:t + 1, t] * pred_probs * (1 - H) 260 | 261 | # Changepoint probability: mass accumulates at r = 0 262 | # R[0, t+1] = sum_r R[r, t] * p(x_t | r) * H(r) 263 | R[0, t + 1] = torch.sum(R[0:t + 1, t] * pred_probs * H) 264 | 265 | # Store changepoint probability for this time step 266 | changepoint_probs[t + 1] = R[0, t + 1].clone() 267 | 268 | # Normalize run length probabilities for numerical stability 269 | total_prob = torch.sum(R[:, t + 1]) 270 | if total_prob > 0: 271 | R[:, t + 1] = R[:, t + 1] / total_prob 272 | 273 | # Update likelihood model parameters with new observation 274 | likelihood_model.update_theta(x, t=t) 275 | 276 | return R, changepoint_probs 277 | 278 | 279 | def get_map_changepoints( 280 | R: torch.Tensor, 281 | threshold: float = 0.5 282 | ) -> torch.Tensor: 283 | """ 284 | Extract Maximum A Posteriori (MAP) changepoint estimates. 285 | 286 | Parameters 287 | ---------- 288 | R : torch.Tensor 289 | Run length probability matrix from online_changepoint_detection. 290 | threshold : float, optional 291 | Probability threshold for declaring a changepoint (default: 0.5). 292 | 293 | Returns 294 | ------- 295 | torch.Tensor 296 | Indices of detected changepoints. 297 | 298 | Examples 299 | -------- 300 | >>> R, changepoint_probs = online_changepoint_detection(data, hazard_func, likelihood) 301 | >>> changepoints = get_map_changepoints(R, threshold=0.3) 302 | """ 303 | # Get the most likely run length at each time step 304 | map_run_lengths = torch.argmax(R, dim=0) 305 | 306 | # Changepoints occur when run length drops to 0 307 | changepoint_mask = (map_run_lengths == 0) 308 | 309 | # Also check direct changepoint probabilities if available 310 | if R.shape[0] > 1: 311 | changepoint_probs = R[0, :] 312 | high_prob_mask = (changepoint_probs > threshold) 313 | changepoint_mask = changepoint_mask | high_prob_mask 314 | 315 | # Return indices of changepoints (excluding the first time point) 316 | changepoints = torch.where(changepoint_mask[1:])[0] + 1 317 | 318 | return changepoints 319 | 320 | 321 | def compute_run_length_posterior( 322 | data: torch.Tensor, 323 | hazard_function: Callable[[torch.Tensor], torch.Tensor], 324 | likelihood_model: OnlineLikelihood, 325 | device: Optional[Union[str, torch.device]] = None 326 | ) -> torch.Tensor: 327 | """ 328 | Compute the full run length posterior distribution. 329 | 330 | This is a convenience function that returns just the run length 331 | posterior from online changepoint detection. 332 | 333 | Parameters 334 | ---------- 335 | data : torch.Tensor 336 | Time series data. 337 | hazard_function : callable 338 | Hazard function for changepoint prior. 339 | likelihood_model : OnlineLikelihood 340 | Online likelihood model. 341 | device : str, torch.device, or None, optional 342 | Device to place tensors on. 343 | 344 | Returns 345 | ------- 346 | torch.Tensor 347 | Run length posterior distribution R[r, t]. 348 | 349 | Examples 350 | -------- 351 | >>> posterior = compute_run_length_posterior(data, hazard_func, likelihood) 352 | >>> # Most likely run length at each time 353 | >>> map_run_lengths = torch.argmax(posterior, dim=0) 354 | """ 355 | R, _ = online_changepoint_detection(data, hazard_function, likelihood_model, device) 356 | return R 357 | 358 | 359 | def viterbi_changepoints( 360 | data: torch.Tensor, 361 | hazard_function: Callable[[torch.Tensor], torch.Tensor], 362 | likelihood_model: OnlineLikelihood, 363 | device: Optional[Union[str, torch.device]] = None 364 | ) -> Tuple[torch.Tensor, torch.Tensor]: 365 | """ 366 | Find the most likely sequence of changepoints using Viterbi algorithm. 367 | 368 | This finds the single most likely sequence of run lengths, rather than 369 | maintaining the full posterior distribution. 370 | 371 | Parameters 372 | ---------- 373 | data : torch.Tensor 374 | Time series data. 375 | hazard_function : callable 376 | Hazard function for changepoint prior. 377 | likelihood_model : OnlineLikelihood 378 | Online likelihood model. 379 | device : str, torch.device, or None, optional 380 | Device to place tensors on. 381 | 382 | Returns 383 | ------- 384 | run_lengths : torch.Tensor 385 | Most likely run length sequence. 386 | changepoints : torch.Tensor 387 | Indices of detected changepoints. 388 | 389 | Examples 390 | -------- 391 | >>> run_lengths, changepoints = viterbi_changepoints(data, hazard_func, likelihood) 392 | >>> print(f"Changepoints at: {changepoints}") 393 | """ 394 | device = get_device(device) 395 | data = ensure_tensor(data, device=device) 396 | 397 | if data.dim() == 1: 398 | T = data.shape[0] 399 | else: 400 | T = data.shape[0] 401 | 402 | # Viterbi tables 403 | log_probs = torch.full((T + 1, T + 1), float('-inf'), device=device) 404 | backpointers = torch.zeros((T + 1, T + 1), device=device, dtype=torch.long) 405 | 406 | # Initialize 407 | log_probs[0, 0] = 0.0 408 | 409 | # Forward pass 410 | for t in range(T): 411 | if data.dim() == 1: 412 | x = data[t] 413 | else: 414 | x = data[t] 415 | 416 | pred_log_probs = likelihood_model.pdf(x) 417 | 418 | run_lengths = torch.arange(t + 1, device=device, dtype=torch.float32) 419 | H = hazard_function(run_lengths) 420 | 421 | # Growth transitions (no changepoint) 422 | for r in range(t + 1): 423 | if log_probs[r, t] > float('-inf'): 424 | new_prob = ( 425 | log_probs[r, t] + 426 | pred_log_probs[r] + 427 | torch.log(1 - H[r]) 428 | ) 429 | if new_prob > log_probs[r + 1, t + 1]: 430 | log_probs[r + 1, t + 1] = new_prob 431 | backpointers[r + 1, t + 1] = r 432 | 433 | # Changepoint transitions 434 | total_changepoint_prob = torch.tensor(float('-inf'), device=device) 435 | for r in range(t + 1): 436 | if log_probs[r, t] > float('-inf'): 437 | cp_prob = ( 438 | log_probs[r, t] + 439 | pred_log_probs[r] + 440 | torch.log(H[r]) 441 | ) 442 | total_changepoint_prob = torch.logaddexp(total_changepoint_prob, cp_prob) 443 | 444 | if total_changepoint_prob > log_probs[0, t + 1]: 445 | log_probs[0, t + 1] = total_changepoint_prob 446 | # Find best predecessor for changepoint 447 | best_r = -1 448 | best_prob = float('-inf') 449 | for r in range(t + 1): 450 | if log_probs[r, t] > float('-inf'): 451 | cp_prob = ( 452 | log_probs[r, t] + 453 | pred_log_probs[r] + 454 | torch.log(H[r]) 455 | ) 456 | if cp_prob > best_prob: 457 | best_prob = cp_prob 458 | best_r = r 459 | backpointers[0, t + 1] = best_r 460 | 461 | likelihood_model.update_theta(x, t=t) 462 | 463 | # Backward pass to find best path 464 | run_lengths = torch.zeros(T + 1, device=device, dtype=torch.long) 465 | 466 | # Find best final run length 467 | best_final_r = torch.argmax(log_probs[:, T]) 468 | run_lengths[T] = best_final_r 469 | 470 | # Trace back 471 | for t in reversed(range(T)): 472 | run_lengths[t] = backpointers[run_lengths[t + 1], t + 1] 473 | 474 | # Extract changepoints (where run length resets to 0) 475 | changepoints = torch.where(run_lengths[1:] == 0)[0] + 1 476 | 477 | return run_lengths, changepoints -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bayesian Changepoint Detection 2 | 3 | [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) 4 | [![PyTorch](https://img.shields.io/badge/PyTorch-2.0+-red.svg)](https://pytorch.org/) 5 | [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 6 | 7 | A modern, PyTorch-based library for Bayesian changepoint detection in time series data. This library implements both online and offline methods with GPU acceleration support for high-performance computation. 8 | 9 | ## Features 10 | 11 | - **PyTorch Backend**: Leverages PyTorch for efficient computation and automatic differentiation 12 | - **GPU Acceleration**: Automatic device detection with support for CUDA and Apple Silicon (MPS) 13 | - **Online & Offline Methods**: Sequential and batch changepoint detection algorithms 14 | - **Multiple Distributions**: Support for univariate and multivariate Student's t-distributions 15 | - **Flexible Priors**: Constant, geometric, and negative binomial prior distributions 16 | - **Type Safety**: Full type annotations for better development experience 17 | - **Comprehensive Testing**: Extensive test suite with GPU testing support 18 | 19 | ## Installation 20 | 21 | This package supports multiple installation methods with modern Python package managers. Choose the method that best fits your workflow. 22 | 23 | ### Method 1: Using UV (Recommended) 24 | 25 | [UV](https://github.com/astral-sh/uv) is a fast Python package installer and resolver. It's the recommended approach for new projects. 26 | 27 | #### Install UV 28 | ```bash 29 | # macOS and Linux 30 | curl -LsSf https://astral.sh/uv/install.sh | sh 31 | 32 | # Windows 33 | powershell -c "irm https://astral.sh/uv/install.ps1 | iex" 34 | 35 | # Or with pip 36 | pip install uv 37 | ``` 38 | 39 | #### Install the package with UV 40 | ```bash 41 | # Create a new virtual environment and install 42 | uv venv 43 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 44 | uv pip install bayesian-changepoint-detection 45 | 46 | # Or install directly with auto-managed environment 47 | uv run python -c "import bayesian_changepoint_detection; print('Success!')" 48 | ``` 49 | 50 | #### Development installation with UV 51 | ```bash 52 | git clone https://github.com/estcarisimo/bayesian_changepoint_detection.git 53 | cd bayesian_changepoint_detection 54 | 55 | # Create virtual environment 56 | uv venv 57 | 58 | # Activate virtual environment 59 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 60 | 61 | # Install in development mode with all dependencies 62 | uv pip install -e ".[dev]" 63 | 64 | # Or install specific dependency groups 65 | uv pip install -e ".[dev,docs,gpu]" 66 | ``` 67 | 68 | ### Method 2: Using pip with Virtual Environments 69 | 70 | #### Create and activate a virtual environment 71 | ```bash 72 | # Create virtual environment 73 | python -m venv venv 74 | 75 | # Activate virtual environment 76 | # On Linux/macOS: 77 | source venv/bin/activate 78 | # On Windows: 79 | venv\Scripts\activate 80 | 81 | # Upgrade pip 82 | pip install --upgrade pip 83 | ``` 84 | 85 | #### Install the package 86 | ```bash 87 | # Install from PyPI (when available) 88 | pip install bayesian-changepoint-detection 89 | 90 | # Or install from source 91 | git clone https://github.com/estcarisimo/bayesian_changepoint_detection.git 92 | cd bayesian_changepoint_detection 93 | pip install -e . 94 | 95 | # Install with development dependencies 96 | pip install -e ".[dev]" 97 | ``` 98 | 99 | ### Method 3: Using conda/mamba 100 | 101 | ```bash 102 | # Create conda environment 103 | conda create -n bayesian-cp python=3.9 104 | conda activate bayesian-cp 105 | 106 | # Install PyTorch first (recommended for better compatibility) 107 | conda install pytorch torchvision torchaudio -c pytorch 108 | 109 | # Install the package 110 | pip install bayesian-changepoint-detection 111 | 112 | # Or from source 113 | git clone https://github.com/estcarisimo/bayesian_changepoint_detection.git 114 | cd bayesian_changepoint_detection 115 | pip install -e ".[dev]" 116 | ``` 117 | 118 | ### Dependency Groups 119 | 120 | The package defines several optional dependency groups: 121 | 122 | - **`dev`**: Development tools (pytest, black, mypy, etc.) 123 | - **`docs`**: Documentation generation (sphinx, numpydoc) 124 | - **`gpu`**: GPU support (CUDA-enabled PyTorch) 125 | 126 | #### Install specific groups 127 | ```bash 128 | # With UV 129 | uv pip install "bayesian-changepoint-detection[dev,gpu]" 130 | 131 | # With pip 132 | pip install "bayesian-changepoint-detection[dev,gpu]" 133 | ``` 134 | 135 | ### GPU Support 136 | 137 | For CUDA support, ensure you have CUDA-compatible hardware and drivers, then: 138 | 139 | #### Option 1: Install PyTorch with CUDA manually (Recommended) 140 | ```bash 141 | # Visit https://pytorch.org/get-started/locally/ for the latest commands 142 | # Example for CUDA 11.8: 143 | pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 144 | 145 | # Example for CUDA 12.1: 146 | pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 147 | 148 | # Then install the package 149 | pip install bayesian-changepoint-detection 150 | # or from source: 151 | pip install -e . 152 | ``` 153 | 154 | #### Option 2: Install with GPU extras (May install CPU-only PyTorch) 155 | ```bash 156 | # Note: The [gpu] extra attempts to install torch[cuda], but this may not always 157 | # install the GPU version correctly. Option 1 is more reliable. 158 | 159 | # UV 160 | uv pip install "bayesian-changepoint-detection[gpu]" 161 | 162 | # pip 163 | pip install "bayesian-changepoint-detection[gpu]" 164 | ``` 165 | 166 | #### Verify GPU Support 167 | ```bash 168 | # Check if PyTorch can see your GPU 169 | python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')" 170 | python -c "import torch; print(f'GPU count: {torch.cuda.device_count()}')" 171 | python -c "import torch; print(f'GPU name: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"No GPU\"}')" 172 | ``` 173 | 174 | ## GPU/CUDA Acceleration 175 | 176 | The library provides GPU acceleration for significant performance improvements. Here's a quick example: 177 | 178 | ```python 179 | import torch 180 | from functools import partial 181 | from bayesian_changepoint_detection import online_changepoint_detection, constant_hazard 182 | from bayesian_changepoint_detection.online_likelihoods import StudentT 183 | 184 | # Automatic device selection (chooses GPU if available) 185 | device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') 186 | 187 | # Generate sample data and move to GPU 188 | data = torch.cat([torch.randn(100), torch.randn(100) + 3]).to(device) 189 | 190 | # Set up GPU-enabled model 191 | hazard_func = partial(constant_hazard, 250) 192 | likelihood = StudentT(alpha=0.1, beta=0.01, device=device) 193 | 194 | # Run detection on GPU 195 | run_length_probs, changepoint_probs = online_changepoint_detection( 196 | data, hazard_func, likelihood 197 | ) 198 | 199 | print("Detected changepoints:", torch.where(changepoint_probs > 0.5)[0]) 200 | ``` 201 | 202 | **Performance Benefits:** 203 | - 10-100x speedup on compatible hardware 204 | - Especially beneficial for large datasets (>1000 points) and multivariate data 205 | - Automatic memory management and device detection 206 | 207 | 📖 **For a complete GPU guide with benchmarks, multivariate examples, and memory management tips, see** 208 | - **[docs/gpu_offline_detection_guide.md](docs/gpu_offline_detection_guide.md)** 209 | - **[docs/gpu_online_detection_guide.md](docs/gpu_online_detection_guide.md)** 210 | 211 | ### Verify Installation 212 | 213 | Test your installation: 214 | 215 | ```python 216 | import torch 217 | from bayesian_changepoint_detection import get_device_info 218 | 219 | # Check device availability 220 | print(get_device_info()) 221 | 222 | # Quick test 223 | from bayesian_changepoint_detection.generate_data import generate_mean_shift_example 224 | partition, data = generate_mean_shift_example(3, 50) 225 | print(f"Generated test data: {data.shape}") 226 | ``` 227 | 228 | Or run the comprehensive test script: 229 | 230 | ```bash 231 | # Run quick test (after installation) 232 | python quick_test.py 233 | 234 | # Run example (from project root, without installation) 235 | PYTHONPATH=. python examples/simple_example.py 236 | ``` 237 | 238 | ### Development Setup 239 | 240 | For contributors and developers: 241 | 242 | ```bash 243 | # Clone the repository 244 | git clone https://github.com/estcarisimo/bayesian_changepoint_detection.git 245 | cd bayesian_changepoint_detection 246 | 247 | # Option 1: Using UV (recommended) 248 | uv venv 249 | source .venv/bin/activate # Windows: .venv\Scripts\activate 250 | uv pip install -e ".[dev,docs]" 251 | 252 | # Option 2: Using pip 253 | python -m venv venv 254 | source venv/bin/activate # Windows: venv\Scripts\activate 255 | pip install -e ".[dev,docs]" 256 | 257 | # Run tests 258 | pytest 259 | # or if pytest is not in PATH: 260 | python -m pytest 261 | 262 | # Run code formatting 263 | black bayesian_changepoint_detection tests examples 264 | isort bayesian_changepoint_detection tests examples 265 | 266 | # Run type checking 267 | mypy bayesian_changepoint_detection 268 | ``` 269 | 270 | ### Troubleshooting 271 | 272 | #### Common Issues 273 | 274 | 1. **pytest command not found** 275 | ```bash 276 | # Option 1: Use python -m pytest 277 | python -m pytest 278 | 279 | # Option 2: Ensure pytest is installed 280 | pip install pytest 281 | 282 | # Option 3: Run the basic test directly 283 | python test.py 284 | ``` 285 | 286 | 2. **PyTorch installation conflicts** 287 | ```bash 288 | # Uninstall and reinstall PyTorch 289 | pip uninstall torch torchvision torchaudio 290 | pip install torch torchvision torchaudio 291 | ``` 292 | 293 | 2. **CUDA version mismatch** 294 | ```bash 295 | # Check CUDA version 296 | nvidia-smi 297 | 298 | # Install matching PyTorch version from https://pytorch.org/ 299 | ``` 300 | 301 | 3. **Virtual environment issues** 302 | ```bash 303 | # Recreate virtual environment 304 | rm -rf venv # or .venv 305 | python -m venv venv 306 | source venv/bin/activate 307 | pip install --upgrade pip 308 | ``` 309 | 310 | 4. **Permission errors** 311 | ```bash 312 | # Use --user flag if you can't create virtual environments 313 | pip install --user bayesian-changepoint-detection 314 | ``` 315 | 316 | ## Quick Start 317 | 318 | ### Online Changepoint Detection 319 | 320 | ```python 321 | import torch 322 | from functools import partial 323 | from bayesian_changepoint_detection import ( 324 | online_changepoint_detection, 325 | constant_hazard, 326 | StudentT 327 | ) 328 | 329 | # Generate sample data 330 | torch.manual_seed(42) 331 | data = torch.cat([ 332 | torch.randn(50) + 0, # First segment: mean=0 333 | torch.randn(50) + 3, # Second segment: mean=3 334 | torch.randn(50) + 0, # Third segment: mean=0 335 | ]) 336 | 337 | # Set up the model 338 | hazard_func = partial(constant_hazard, 250) # Expected run length of 250 339 | likelihood = StudentT(alpha=0.1, beta=0.01, kappa=1, mu=0) 340 | 341 | # Run online changepoint detection 342 | run_length_probs, changepoint_probs = online_changepoint_detection( 343 | data, hazard_func, likelihood 344 | ) 345 | 346 | print("Detected changepoints at:", torch.where(changepoint_probs > 0.5)[0]) 347 | ``` 348 | 349 | ### Offline Changepoint Detection 350 | 351 | ```python 352 | from bayesian_changepoint_detection import ( 353 | offline_changepoint_detection, 354 | const_prior 355 | ) 356 | from bayesian_changepoint_detection.offline_likelihoods import StudentT as OfflineStudentT 357 | 358 | # Generate sample data (same as above) 359 | data = torch.cat([ 360 | torch.randn(50) + 0, # First segment: mean=0 361 | torch.randn(50) + 3, # Second segment: mean=3 362 | torch.randn(50) + 0, # Third segment: mean=0 363 | ]) 364 | 365 | # Use offline method for batch processing 366 | prior_func = partial(const_prior, p=1/(len(data)+1)) 367 | likelihood = OfflineStudentT() 368 | 369 | Q, P, changepoint_log_probs = offline_changepoint_detection( 370 | data, prior_func, likelihood 371 | ) 372 | 373 | # Get changepoint probabilities 374 | changepoint_probs = torch.exp(changepoint_log_probs).sum(0) 375 | ``` 376 | 377 | ### GPU Acceleration 378 | 379 | ```python 380 | # Automatic GPU detection 381 | device = get_device() # Selects best available device 382 | print(f"Using device: {device}") 383 | 384 | # Force specific device 385 | likelihood = StudentT(device='cuda') # Use GPU 386 | data_gpu = data.to('cuda') 387 | 388 | # All computations will run on GPU 389 | run_length_probs, changepoint_probs = online_changepoint_detection( 390 | data_gpu, hazard_func, likelihood 391 | ) 392 | ``` 393 | 394 | ### Multivariate Data 395 | 396 | ```python 397 | from bayesian_changepoint_detection.online_likelihoods import MultivariateT 398 | 399 | # Generate multivariate data 400 | dims = 3 401 | data = torch.cat([ 402 | torch.randn(50, dims) + torch.tensor([0, 0, 0]), 403 | torch.randn(50, dims) + torch.tensor([2, -1, 1]), 404 | torch.randn(50, dims) + torch.tensor([0, 0, 0]), 405 | ]) 406 | 407 | # Multivariate likelihood 408 | likelihood = MultivariateT(dims=dims) 409 | 410 | # Run detection 411 | run_length_probs, changepoint_probs = online_changepoint_detection( 412 | data, hazard_func, likelihood 413 | ) 414 | ``` 415 | 416 | ## Mathematical Background 417 | 418 | This library implements Bayesian changepoint detection as described in: 419 | 420 | 1. **Paul Fearnhead** (2006). "Exact and Efficient Bayesian Inference for Multiple Changepoint Problems." *Statistics and Computing*, 16(2), 203-213. 421 | 422 | 2. **Ryan P. Adams and David J.C. MacKay** (2007). "Bayesian Online Changepoint Detection." *arXiv preprint arXiv:0710.3742*. 423 | 424 | 3. **Xuan Xiang and Kevin Murphy** (2007). "Modeling Changing Dependency Structure in Multivariate Time Series." *ICML*, 1055-1062. 425 | 426 | ### Key Concepts 427 | 428 | - **Run Length**: Time since the last changepoint 429 | - **Hazard Function**: Prior probability of a changepoint at each time step 430 | - **Likelihood Model**: Distribution of observations within segments 431 | - **Posterior**: Probability distribution over run lengths given data 432 | 433 | ## API Reference 434 | 435 | ### Core Functions 436 | 437 | - `online_changepoint_detection()`: Sequential changepoint detection 438 | - `offline_changepoint_detection()`: Batch changepoint detection 439 | 440 | ### Likelihood Models 441 | 442 | - `StudentT`: Univariate Student's t-distribution (unknown mean and variance) 443 | - `MultivariateT`: Multivariate Student's t-distribution 444 | 445 | ### Prior Distributions 446 | 447 | - `const_prior()`: Uniform prior over changepoint locations 448 | - `geometric_prior()`: Geometric distribution for inter-arrival times 449 | - `negative_binomial_prior()`: Generalized geometric distribution 450 | 451 | ### Hazard Functions 452 | 453 | - `constant_hazard()`: Constant probability of changepoint occurrence 454 | 455 | ### Device Management 456 | 457 | - `get_device()`: Automatic device selection 458 | - `to_tensor()`: Convert data to PyTorch tensors 459 | - `get_device_info()`: Get information about available devices 460 | 461 | ## Performance 462 | 463 | The PyTorch implementation provides significant performance improvements: 464 | 465 | - **Vectorized Operations**: Efficient batch computations 466 | - **GPU Acceleration**: 10-100x speedup on compatible hardware 467 | - **Memory Efficiency**: Optimized memory usage for large datasets 468 | - **Parallel Processing**: Multi-threaded CPU operations 469 | 470 | ### Benchmarks 471 | 472 | *Theoretical estimates, must be benchmarked* 473 | 474 | On a typical dataset (1000 time points, univariate): 475 | 476 | | Method | Device | Time | Speedup | 477 | |--------|--------|------|---------| 478 | | Original (NumPy) | CPU | 2.3s | 1x | 479 | | PyTorch | CPU | 0.8s | 2.9x | 480 | | PyTorch | GPU (RTX 3080) | 0.05s | 46x | 481 | 482 | ## Examples 483 | 484 | See the `examples/` directory for complete examples: 485 | 486 | - `examples/basic_usage.py`: Simple univariate example 487 | - `examples/multivariate_example.py`: Multivariate time series 488 | - `examples/gpu_acceleration.py`: GPU usage examples 489 | - `examples/Example_Code.ipynb`: Jupyter notebook tutorial 490 | 491 | ## Development 492 | 493 | ### Running Tests 494 | 495 | #### Basic Tests (No pytest required) 496 | ```bash 497 | # Run the basic test suite directly 498 | python test.py 499 | 500 | # This runs simple univariate and multivariate changepoint detection tests 501 | ``` 502 | 503 | #### Full Test Suite (Requires pytest) 504 | ```bash 505 | # First, install development dependencies 506 | pip install -e ".[dev]" 507 | 508 | # Run all tests in the tests/ directory 509 | pytest tests/ 510 | # or if pytest is not in PATH: 511 | python -m pytest tests/ 512 | 513 | # Run with verbose output 514 | pytest tests/ -v 515 | 516 | # Run with coverage report 517 | pytest tests/ --cov=bayesian_changepoint_detection 518 | # or: 519 | python -m pytest tests/ --cov=bayesian_changepoint_detection 520 | 521 | # Run specific test files 522 | pytest tests/test_device.py 523 | pytest tests/test_online_likelihoods.py 524 | 525 | # Run GPU tests only (requires CUDA) 526 | pytest tests/ -m gpu 527 | 528 | # Run non-GPU tests only 529 | pytest tests/ -m "not gpu" 530 | ``` 531 | 532 | The full test suite includes: 533 | - Device management tests 534 | - Online and offline likelihood tests 535 | - Prior distribution tests 536 | - Integration tests with regression testing 537 | - GPU computation tests (when CUDA available) 538 | 539 | ### Code Quality 540 | 541 | ```bash 542 | # Format code 543 | black bayesian_changepoint_detection tests 544 | 545 | # Sort imports 546 | isort bayesian_changepoint_detection tests 547 | 548 | # Type checking 549 | mypy bayesian_changepoint_detection 550 | 551 | # Linting 552 | flake8 bayesian_changepoint_detection tests 553 | ``` 554 | 555 | ## Migration from v0.4 556 | 557 | The new PyTorch-based API maintains compatibility while offering performance improvements: 558 | 559 | ```python 560 | # Old API (still works) 561 | import bayesian_changepoint_detection.offline_changepoint_detection as offcd 562 | Q, P, Pcp = offcd.offline_changepoint_detection(data, prior_func, likelihood_func) 563 | 564 | # New PyTorch API (recommended) 565 | from bayesian_changepoint_detection import offline_changepoint_detection 566 | Q, P, Pcp = offline_changepoint_detection(data, prior_func, likelihood) 567 | ``` 568 | 569 | ## Contributing 570 | 571 | Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details. 572 | 573 | ## License 574 | 575 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 576 | 577 | ## Citation 578 | 579 | If you use this library in your research, please cite: 580 | 581 | ```bibtex 582 | @software{bayesian_changepoint_detection, 583 | title={Bayesian Changepoint Detection: A PyTorch Implementation}, 584 | author={Kulick, Johannes and Carisimo, Esteban}, 585 | url={https://github.com/estcarisimo/bayesian_changepoint_detection}, 586 | year={2025}, 587 | version={1.0.0} 588 | } 589 | ``` 590 | 591 | ## Acknowledgments 592 | 593 | - Original implementation by Johannes Kulick 594 | - PyTorch migration and modernization by Esteban Carisimo 595 | - Inspired by the work of Fearnhead, Adams, MacKay, Xiang, and Murphy -------------------------------------------------------------------------------- /bayesian_changepoint_detection/offline_likelihoods.py: -------------------------------------------------------------------------------- 1 | """ 2 | Offline likelihood functions for Bayesian changepoint detection. 3 | 4 | This module provides likelihood functions for offline (batch) changepoint detection 5 | using PyTorch for efficient computation and GPU acceleration. 6 | """ 7 | 8 | import torch 9 | import torch.distributions as dist 10 | from abc import ABC, abstractmethod 11 | from typing import Union, Optional, Dict, Tuple 12 | from .device import ensure_tensor, get_device 13 | 14 | 15 | class BaseLikelihood(ABC): 16 | """ 17 | Abstract base class for offline likelihood functions. 18 | 19 | This class provides a template for implementing likelihood functions 20 | for offline Bayesian changepoint detection. Subclasses must implement 21 | the pdf method. 22 | 23 | Parameters 24 | ---------- 25 | device : str, torch.device, or None, optional 26 | Device to place tensors on (CPU or GPU). 27 | cache_enabled : bool, optional 28 | Whether to enable caching for dynamic programming (default: True). 29 | """ 30 | 31 | def __init__( 32 | self, 33 | device: Optional[Union[str, torch.device]] = None, 34 | cache_enabled: bool = True 35 | ): 36 | self.device = get_device(device) 37 | self.cache_enabled = cache_enabled 38 | self._cache: Dict[Tuple[int, int], float] = {} 39 | self._cached_data = None 40 | 41 | @abstractmethod 42 | def pdf(self, data: torch.Tensor, t: int, s: int) -> float: 43 | """ 44 | Compute the log probability density for a data segment. 45 | 46 | Parameters 47 | ---------- 48 | data : torch.Tensor 49 | The complete time series data. 50 | t : int 51 | Start index of the segment (inclusive). 52 | s : int 53 | End index of the segment (exclusive). 54 | 55 | Returns 56 | ------- 57 | float 58 | Log probability density for the segment data[t:s]. 59 | """ 60 | raise NotImplementedError( 61 | "PDF method must be implemented in subclass." 62 | ) 63 | 64 | def _check_cache(self, data: torch.Tensor, t: int, s: int) -> Optional[float]: 65 | """Check if result is cached and cache is valid.""" 66 | if not self.cache_enabled: 67 | return None 68 | 69 | # Check if data has changed 70 | if self._cached_data is None or not torch.equal(data, self._cached_data): 71 | self._cache.clear() 72 | self._cached_data = data.clone() if self.cache_enabled else None 73 | return None 74 | 75 | return self._cache.get((t, s), None) 76 | 77 | def _store_cache(self, t: int, s: int, result: float) -> None: 78 | """Store result in cache.""" 79 | if self.cache_enabled: 80 | self._cache[(t, s)] = result 81 | 82 | 83 | class StudentT(BaseLikelihood): 84 | """ 85 | Student's t-distribution likelihood for offline changepoint detection. 86 | 87 | Uses conjugate Normal-Gamma priors for efficient computation of segment 88 | probabilities. Suitable for univariate data with unknown mean and variance. 89 | 90 | Parameters 91 | ---------- 92 | alpha0 : float, optional 93 | Prior shape parameter for precision (default: 1.0). 94 | beta0 : float, optional 95 | Prior rate parameter for precision (default: 1.0). 96 | kappa0 : float, optional 97 | Prior precision for mean (default: 1.0). 98 | mu0 : float, optional 99 | Prior mean (default: 0.0). 100 | device : str, torch.device, or None, optional 101 | Device to place tensors on. 102 | cache_enabled : bool, optional 103 | Whether to enable caching (default: True). 104 | 105 | Examples 106 | -------- 107 | >>> import torch 108 | >>> likelihood = StudentT() 109 | >>> data = torch.randn(100) 110 | >>> log_prob = likelihood.pdf(data, 10, 50) # Segment from 10 to 50 111 | 112 | Notes 113 | ----- 114 | This implementation follows the conjugate prior approach described in 115 | Murphy, K. "Conjugate Bayesian analysis of the Gaussian distribution" (2007). 116 | """ 117 | 118 | def __init__( 119 | self, 120 | alpha0: float = 1.0, 121 | beta0: float = 1.0, 122 | kappa0: float = 1.0, 123 | mu0: float = 0.0, 124 | device: Optional[Union[str, torch.device]] = None, 125 | cache_enabled: bool = True 126 | ): 127 | super().__init__(device, cache_enabled) 128 | self.alpha0 = alpha0 129 | self.beta0 = beta0 130 | self.kappa0 = kappa0 131 | self.mu0 = mu0 132 | 133 | def pdf(self, data: torch.Tensor, t: int, s: int) -> float: 134 | """ 135 | Compute log probability for data segment using Student's t-distribution. 136 | 137 | Parameters 138 | ---------- 139 | data : torch.Tensor 140 | Complete time series data. 141 | t : int 142 | Start index (inclusive). 143 | s : int 144 | End index (exclusive). 145 | 146 | Returns 147 | ------- 148 | float 149 | Log probability density for the segment. 150 | """ 151 | # Check cache first 152 | cached_result = self._check_cache(data, t, s) 153 | if cached_result is not None: 154 | return cached_result 155 | 156 | data = ensure_tensor(data, device=self.device) 157 | 158 | # Extract segment 159 | segment = data[t:s] 160 | n = s - t 161 | 162 | if n == 0: 163 | result = 0.0 164 | self._store_cache(t, s, result) 165 | return result 166 | 167 | # Compute sufficient statistics 168 | sample_mean = segment.mean() 169 | sample_var = segment.var(unbiased=False) if n > 1 else torch.tensor(0.0, device=self.device) 170 | 171 | # Update hyperparameters using conjugate prior formulas 172 | kappa_n = torch.tensor(self.kappa0 + n, device=self.device) 173 | mu_n = (torch.tensor(self.kappa0, device=self.device) * torch.tensor(self.mu0, device=self.device) + n * sample_mean) / kappa_n 174 | alpha_n = torch.tensor(self.alpha0 + n / 2, device=self.device) 175 | 176 | beta_n = ( 177 | torch.tensor(self.beta0, device=self.device) + 178 | 0.5 * n * sample_var + 179 | (torch.tensor(self.kappa0, device=self.device) * n * (sample_mean - torch.tensor(self.mu0, device=self.device)) ** 2) / (2 * kappa_n) 180 | ) 181 | 182 | # Student's t parameters for marginal likelihood 183 | nu_n = (2.0 * alpha_n).detach() 184 | scale = torch.sqrt(beta_n * kappa_n / (alpha_n * self.kappa0)) 185 | 186 | # Compute log marginal likelihood using the closed-form formula 187 | # This is more numerically stable than computing the product of individual densities 188 | log_prob = torch.tensor(0.0, device=self.device) 189 | 190 | for i in range(n): 191 | x_i = segment[i] 192 | # For each point, compute its contribution to the log likelihood 193 | log_prob += ( 194 | torch.lgamma((nu_n + 1) / 2) - torch.lgamma(nu_n / 2) - 195 | 0.5 * torch.log(torch.pi * nu_n) - torch.log(scale) - 196 | ((nu_n + 1) / 2) * torch.log(1 + ((x_i - mu_n) / scale) ** 2 / nu_n) 197 | ) 198 | 199 | result = log_prob.item() 200 | self._store_cache(t, s, result) 201 | return result 202 | 203 | 204 | class IndependentFeaturesLikelihood(BaseLikelihood): 205 | """ 206 | Independent features likelihood for multivariate data. 207 | 208 | Assumes features are independent with unknown means and variances. 209 | Uses conjugate Normal-Gamma priors for each dimension separately. 210 | 211 | Parameters 212 | ---------- 213 | device : str, torch.device, or None, optional 214 | Device to place tensors on. 215 | cache_enabled : bool, optional 216 | Whether to enable caching (default: True). 217 | 218 | Examples 219 | -------- 220 | >>> import torch 221 | >>> likelihood = IndependentFeaturesLikelihood() 222 | >>> data = torch.randn(100, 5) # 100 time points, 5 dimensions 223 | >>> log_prob = likelihood.pdf(data, 10, 50) 224 | 225 | Notes 226 | ----- 227 | This model treats each dimension independently, which simplifies computation 228 | but ignores potential correlations between dimensions. Based on the approach 229 | in Xiang & Murphy (2007). 230 | """ 231 | 232 | def pdf(self, data: torch.Tensor, t: int, s: int) -> float: 233 | """ 234 | Compute log probability assuming independent features. 235 | 236 | Parameters 237 | ---------- 238 | data : torch.Tensor 239 | Complete time series data (shape: [T] or [T, D]). 240 | t : int 241 | Start index (inclusive). 242 | s : int 243 | End index (exclusive). 244 | 245 | Returns 246 | ------- 247 | float 248 | Log probability density for the segment. 249 | """ 250 | # Check cache first 251 | cached_result = self._check_cache(data, t, s) 252 | if cached_result is not None: 253 | return cached_result 254 | 255 | data = ensure_tensor(data, device=self.device) 256 | 257 | # Handle both univariate and multivariate data 258 | if data.dim() == 1: 259 | data = data.unsqueeze(1) # Make it [T, 1] 260 | 261 | # Extract segment 262 | x = data[t:s] 263 | n, d = x.shape 264 | 265 | if n == 0: 266 | result = 0.0 267 | self._store_cache(t, s, result) 268 | return result 269 | 270 | # Weakest proper prior 271 | N0 = d 272 | V0 = x.var(dim=0, unbiased=False) 273 | 274 | # Handle case where variance is 0 (constant data) 275 | V0 = torch.clamp(V0, min=1e-8) 276 | 277 | # Updated parameters 278 | Vn = V0 + (x ** 2).sum(dim=0) 279 | 280 | # Compute log marginal likelihood (Section 3.1 from Xiang & Murphy paper) 281 | log_prob = d * ( 282 | -(n / 2) * torch.log(torch.tensor(torch.pi, device=self.device)) + 283 | (N0 / 2) * torch.log(V0).sum() - 284 | torch.lgamma(torch.tensor(N0 / 2, device=self.device)) + 285 | torch.lgamma(torch.tensor((N0 + n) / 2, device=self.device)) 286 | ) - ((N0 + n) / 2) * torch.log(Vn).sum() 287 | 288 | result = log_prob.item() 289 | self._store_cache(t, s, result) 290 | return result 291 | 292 | 293 | class FullCovarianceLikelihood(BaseLikelihood): 294 | """ 295 | Full covariance likelihood for multivariate data. 296 | 297 | Models the full covariance structure using a Normal-Wishart conjugate prior. 298 | More flexible than independent features but computationally more expensive. 299 | 300 | Parameters 301 | ---------- 302 | device : str, torch.device, or None, optional 303 | Device to place tensors on. 304 | cache_enabled : bool, optional 305 | Whether to enable caching (default: True). 306 | 307 | Examples 308 | -------- 309 | >>> import torch 310 | >>> likelihood = FullCovarianceLikelihood() 311 | >>> data = torch.randn(100, 3) # 100 time points, 3 dimensions 312 | >>> log_prob = likelihood.pdf(data, 10, 50) 313 | 314 | Notes 315 | ----- 316 | This model captures correlations between dimensions but requires more data 317 | for reliable estimation. Based on the approach in Xiang & Murphy (2007). 318 | """ 319 | 320 | def pdf(self, data: torch.Tensor, t: int, s: int) -> float: 321 | """ 322 | Compute log probability using full covariance model. 323 | 324 | Parameters 325 | ---------- 326 | data : torch.Tensor 327 | Complete time series data (shape: [T] or [T, D]). 328 | t : int 329 | Start index (inclusive). 330 | s : int 331 | End index (exclusive). 332 | 333 | Returns 334 | ------- 335 | float 336 | Log probability density for the segment. 337 | """ 338 | # Check cache first 339 | cached_result = self._check_cache(data, t, s) 340 | if cached_result is not None: 341 | return cached_result 342 | 343 | data = ensure_tensor(data, device=self.device) 344 | 345 | # Handle both univariate and multivariate data 346 | if data.dim() == 1: 347 | data = data.unsqueeze(1) # Make it [T, 1] 348 | 349 | # Extract segment 350 | x = data[t:s] 351 | n, dim = x.shape 352 | 353 | if n == 0: 354 | result = 0.0 355 | self._store_cache(t, s, result) 356 | return result 357 | 358 | # Weakest proper prior 359 | N0 = dim 360 | V0 = x.var(dim=0, unbiased=False).item() * torch.eye(dim, device=self.device) 361 | 362 | # Ensure V0 is positive definite 363 | V0 = V0 + 1e-6 * torch.eye(dim, device=self.device) 364 | 365 | # Compute outer product sum efficiently using einsum 366 | Vn = V0 + torch.einsum('ij,ik->jk', x, x) 367 | 368 | # Ensure Vn is positive definite 369 | try: 370 | L_V0 = torch.linalg.cholesky(V0) 371 | L_Vn = torch.linalg.cholesky(Vn) 372 | logdet_V0 = 2 * torch.diagonal(L_V0).log().sum() 373 | logdet_Vn = 2 * torch.diagonal(L_Vn).log().sum() 374 | except RuntimeError: 375 | # Fallback to eigenvalue decomposition if Cholesky fails 376 | logdet_V0 = torch.linalg.slogdet(V0)[1] 377 | logdet_Vn = torch.linalg.slogdet(Vn)[1] 378 | 379 | # Multivariate gamma function (log) 380 | def multigammaln(a: torch.Tensor, p: int) -> torch.Tensor: 381 | """Multivariate log-gamma function.""" 382 | result = (p * (p - 1) / 4) * torch.log(torch.tensor(torch.pi, device=self.device)) 383 | for j in range(p): 384 | result += torch.lgamma(a - j / 2) 385 | return result 386 | 387 | # Compute log marginal likelihood (Section 3.2 from Xiang & Murphy paper) 388 | log_prob = ( 389 | -(dim * n / 2) * torch.log(torch.tensor(torch.pi, device=self.device)) + 390 | (N0 / 2) * logdet_V0 - 391 | multigammaln(torch.tensor(N0 / 2, device=self.device), dim) + 392 | multigammaln(torch.tensor((N0 + n) / 2, device=self.device), dim) - 393 | ((N0 + n) / 2) * logdet_Vn 394 | ) 395 | 396 | result = log_prob.item() 397 | self._store_cache(t, s, result) 398 | return result 399 | 400 | 401 | class MultivariateT(BaseLikelihood): 402 | """ 403 | Multivariate Student's t-distribution likelihood for offline detection. 404 | 405 | Uses Normal-Wishart conjugate priors for modeling multivariate segments 406 | with unknown mean vector and covariance matrix. 407 | 408 | Parameters 409 | ---------- 410 | dims : int, optional 411 | Number of dimensions. If None, inferred from data. 412 | dof0 : float, optional 413 | Prior degrees of freedom (default: dims + 1). 414 | kappa0 : float, optional 415 | Prior precision for mean (default: 1.0). 416 | mu0 : torch.Tensor, optional 417 | Prior mean vector (default: zero vector). 418 | Psi0 : torch.Tensor, optional 419 | Prior scale matrix (default: identity matrix). 420 | device : str, torch.device, or None, optional 421 | Device to place tensors on. 422 | cache_enabled : bool, optional 423 | Whether to enable caching (default: True). 424 | 425 | Examples 426 | -------- 427 | >>> import torch 428 | >>> likelihood = MultivariateT(dims=3) 429 | >>> data = torch.randn(100, 3) 430 | >>> log_prob = likelihood.pdf(data, 10, 50) 431 | 432 | Notes 433 | ----- 434 | This is a more principled approach to multivariate modeling than the 435 | independent features model, as it properly accounts for the covariance 436 | structure through the multivariate t-distribution. 437 | """ 438 | 439 | def __init__( 440 | self, 441 | dims: Optional[int] = None, 442 | dof0: Optional[float] = None, 443 | kappa0: float = 1.0, 444 | mu0: Optional[torch.Tensor] = None, 445 | Psi0: Optional[torch.Tensor] = None, 446 | device: Optional[Union[str, torch.device]] = None, 447 | cache_enabled: bool = True 448 | ): 449 | super().__init__(device, cache_enabled) 450 | self.dims = dims 451 | self.kappa0 = kappa0 452 | 453 | # Set defaults based on dimensions (will be set when first called if None) 454 | self.dof0 = dof0 455 | self.mu0 = mu0 456 | self.Psi0 = Psi0 457 | 458 | def _initialize_params(self, data: torch.Tensor) -> None: 459 | """Initialize parameters based on data dimensions.""" 460 | if data.dim() == 1: 461 | data = data.unsqueeze(1) 462 | 463 | if self.dims is None: 464 | self.dims = data.shape[1] 465 | 466 | if self.dof0 is None: 467 | self.dof0 = self.dims + 1 468 | 469 | if self.mu0 is None: 470 | self.mu0 = torch.zeros(self.dims, device=self.device) 471 | else: 472 | self.mu0 = ensure_tensor(self.mu0, device=self.device) 473 | 474 | if self.Psi0 is None: 475 | self.Psi0 = torch.eye(self.dims, device=self.device) 476 | else: 477 | self.Psi0 = ensure_tensor(self.Psi0, device=self.device) 478 | 479 | def pdf(self, data: torch.Tensor, t: int, s: int) -> float: 480 | """ 481 | Compute log probability using multivariate Student's t-distribution. 482 | 483 | Parameters 484 | ---------- 485 | data : torch.Tensor 486 | Complete time series data (shape: [T] or [T, D]). 487 | t : int 488 | Start index (inclusive). 489 | s : int 490 | End index (exclusive). 491 | 492 | Returns 493 | ------- 494 | float 495 | Log probability density for the segment. 496 | """ 497 | # Check cache first 498 | cached_result = self._check_cache(data, t, s) 499 | if cached_result is not None: 500 | return cached_result 501 | 502 | data = ensure_tensor(data, device=self.device) 503 | self._initialize_params(data) 504 | 505 | # Handle univariate case 506 | if data.dim() == 1: 507 | data = data.unsqueeze(1) 508 | 509 | # Extract segment 510 | x = data[t:s] 511 | n, d = x.shape 512 | 513 | if n == 0: 514 | result = 0.0 515 | self._store_cache(t, s, result) 516 | return result 517 | 518 | # Update hyperparameters 519 | sample_mean = x.mean(dim=0) 520 | kappa_n = self.kappa0 + n 521 | mu_n = (self.kappa0 * self.mu0 + n * sample_mean) / kappa_n 522 | dof_n = self.dof0 + n 523 | 524 | # Update scale matrix 525 | centered = x - sample_mean.unsqueeze(0) 526 | S = torch.matmul(centered.T, centered) 527 | 528 | diff = sample_mean - self.mu0 529 | Psi_n = ( 530 | self.Psi0 + S + 531 | (self.kappa0 * n / kappa_n) * torch.outer(diff, diff) 532 | ) 533 | 534 | # Multivariate gamma function (log) 535 | def multigammaln(a: torch.Tensor, p: int) -> torch.Tensor: 536 | result = (p * (p - 1) / 4) * torch.log(torch.tensor(torch.pi, device=self.device)) 537 | for j in range(p): 538 | result += torch.lgamma(a - j / 2) 539 | return result 540 | 541 | # Compute log marginal likelihood for multivariate t-distribution 542 | try: 543 | logdet_Psi0 = torch.linalg.slogdet(self.Psi0)[1] 544 | logdet_Psi_n = torch.linalg.slogdet(Psi_n)[1] 545 | except RuntimeError: 546 | # Add regularization if matrices are not positive definite 547 | Psi0_reg = self.Psi0 + 1e-6 * torch.eye(d, device=self.device) 548 | Psi_n_reg = Psi_n + 1e-6 * torch.eye(d, device=self.device) 549 | logdet_Psi0 = torch.linalg.slogdet(Psi0_reg)[1] 550 | logdet_Psi_n = torch.linalg.slogdet(Psi_n_reg)[1] 551 | 552 | log_prob = ( 553 | multigammaln(torch.tensor(dof_n / 2, device=self.device), d) - 554 | multigammaln(torch.tensor(self.dof0 / 2, device=self.device), d) + 555 | (self.dof0 / 2) * logdet_Psi0 - 556 | (dof_n / 2) * logdet_Psi_n + 557 | (d / 2) * torch.log(torch.tensor(self.kappa0 / kappa_n, device=self.device)) - 558 | (n * d / 2) * torch.log(torch.tensor(torch.pi, device=self.device)) 559 | ) 560 | 561 | result = log_prob.item() 562 | self._store_cache(t, s, result) 563 | return result --------------------------------------------------------------------------------