├── 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 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
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 | [](https://www.python.org/downloads/)
4 | [](https://pytorch.org/)
5 | [](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
--------------------------------------------------------------------------------