Repository: TutteInstitute/evoc
Branch: main
Commit: a58b66e402bd
Files: 68
Total size: 1.1 MB
Directory structure:
gitextract_hnrqw2lv/
├── .gitignore
├── .readthedocs.yaml
├── LICENSE
├── README.rst
├── azure-pipelines.yml
├── doc/
│ ├── Makefile
│ ├── README.md
│ ├── build_docs.bat
│ ├── build_docs.sh
│ ├── requirements.txt
│ └── source/
│ ├── _static/
│ │ └── custom.css
│ ├── api/
│ │ ├── evoc.cluster_trees.rst
│ │ ├── evoc.clustering.rst
│ │ ├── evoc.clustering_utilities.rst
│ │ ├── generated/
│ │ │ ├── evoc.EVoC.rst
│ │ │ ├── evoc.boruvka.parallel_boruvka.rst
│ │ │ ├── evoc.cluster_trees.condense_tree.rst
│ │ │ ├── evoc.cluster_trees.extract_leaves.rst
│ │ │ ├── evoc.cluster_trees.get_cluster_label_vector.rst
│ │ │ ├── evoc.cluster_trees.get_point_membership_strength_vector.rst
│ │ │ ├── evoc.cluster_trees.mst_to_linkage_tree.rst
│ │ │ ├── evoc.clustering_utilities.binary_search_for_n_clusters.rst
│ │ │ ├── evoc.clustering_utilities.build_cluster_tree.rst
│ │ │ ├── evoc.clustering_utilities.find_duplicates.rst
│ │ │ ├── evoc.clustering_utilities.find_peaks.rst
│ │ │ ├── evoc.clustering_utilities.select_diverse_peaks.rst
│ │ │ ├── evoc.evoc_clusters.rst
│ │ │ ├── evoc.graph_construction.neighbor_graph_matrix.rst
│ │ │ ├── evoc.knn_graph.knn_graph.rst
│ │ │ ├── evoc.label_propagation.label_propagation_init.rst
│ │ │ ├── evoc.node_embedding.node_embedding.rst
│ │ │ └── evoc.numba_kdtree.build_kdtree.rst
│ │ └── index.rst
│ ├── benchmarks.ipynb
│ ├── changelog.rst
│ ├── conf.py
│ ├── examples.rst
│ ├── index.rst
│ ├── installation.rst
│ ├── quickstart.rst
│ └── user_guide.rst
├── evoc/
│ ├── __init__.py
│ ├── boruvka.py
│ ├── cluster_trees.py
│ ├── clustering.py
│ ├── clustering_utilities.py
│ ├── common_nndescent.py
│ ├── disjoint_set.py
│ ├── float_nndescent.py
│ ├── graph_construction.py
│ ├── int8_nndescent.py
│ ├── knn_graph.py
│ ├── label_propagation.py
│ ├── nested_parallelism.py
│ ├── node_embedding.py
│ ├── numba_kdtree.py
│ ├── tests/
│ │ ├── test_boruvka.py
│ │ ├── test_cluster_trees.py
│ │ ├── test_clustering.py
│ │ ├── test_knn_graph.py
│ │ ├── test_knn_graph_performance.py
│ │ ├── test_numba_kdtree.py
│ │ └── test_numba_kdtree_performance.py
│ └── uint8_nndescent.py
├── pyproject.toml
├── pytest.ini
├── scripts/
│ └── run_performance_tests.py
└── setup.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/.gitignore
.idea/evoc.iml
.idea/misc.xml
.idea/modules.xml
.idea/vcs.xml
.idea/inspectionProfiles/profiles_settings.xml
.idea/inspectionProfiles/Project_Default.xml
.vscode/settings.json
================================================
FILE: .readthedocs.yaml
================================================
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# You can also specify other tools here
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: doc/source/conf.py
fail_on_warning: false
# If using Sphinx, optionally build your docs in additional formats such as PDF
formats:
- pdf
- epub
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: doc/requirements.txt
- method: pip
path: .
extra_requirements:
- docs
# Optional but recommended, specify the Python version to use
# https://docs.readthedocs.io/en/stable/config-file/v2.html#python
================================================
FILE: LICENSE
================================================
BSD 2-Clause License
Copyright (c) 2024, Tutte Institute for Mathematics and Computing
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: README.rst
================================================
.. image:: doc/evoc_logo_horizontal.png
:width: 600
:align: center
:alt: EVōC Logo
====
EVōC
====
EVōC (pronounced as "evoke") provides Embedding Vector Oriented Clustering.
EVōC is a library for fast and flexible clustering of large datasets of high dimensional embedding vectors.
If you have CLIP-vectors, outputs from sentence-transformers, or openAI, or Cohere embed, and you want
to quickly get good clusters out this is the library for you. EVōC takes all the good parts of the
combination of UMAP + HDBSCAN for embedding clustering, improves upon them, and removes all
the time-consuming parts. By specializing directly to embedding vectors we can get good
quality clustering with fewer hyper-parameters to tune and in a fraction of the time.
EVōC is the library to use if you want:
* Fast clustering of embedding vectors on CPU
* Multi-granularity clustering, and automatic selection of the number of clusters
* Clustering of int8 or binary quantized embedding vectors that works out-of-the-box
As of now this is very much an early beta version of the library. Things can and will break right now.
We would welcome feedback, use cases and feature suggestions however.
-------------
Documentation
-------------
The full documentation is available on Read the Docs:
`https://evoc.readthedocs.io/en/latest/ `_
-----------
Basic Usage
-----------
EVōC follows the scikit-learn API, so it should be familiar to most users. You can use EVōC wherever
you might have previously been using other sklearn clustering algorithms. Here is a simple example
.. code-block:: python
import evoc
from sklearn.datasets import make_blobs
data, _ = make_blobs(n_samples=100_000, n_features=1024, centers=100)
clusterer = evoc.EVoC()
cluster_labels = clusterer.fit_predict(data)
Some more unique features include the generation of multiple layers of cluster granularity,
the ability to extract a hierarchy of clusters across those layers, and automatic duplicate
(or very near duplicate) detection.
.. code-block:: python
import evoc
from sklearn.datasets import make_blobs
data, _ = make_blobs(n_samples=100_000, n_features=1024, centers=100)
clusterer = evoc.EVoC()
cluster_labels = clusterer.fit_predict(data)
cluster_layers = clusterer.cluster_layers_
hierarchy = clusterer.cluster_tree_
potential_duplicates = clusterer.duplicates_
The cluster layers are a list of cluster label vectors with the first being the finest grained
and later layers being coarser grained. This is ideal for layered topic modelling and use with
`DataMapPlot `_. See
`this data map `_
for an example of using these layered clusters in topic modelling (zoom in to access finer
grained topics).
------------
Installation
------------
EVōC has a small set of dependencies:
* numpy
* scikit-learn
* numba
* tqdm
* tbb
You can install EVōC from PyPI using pip:
.. code-block:: bash
pip install evoc
To install the latest version of EVōC from source:
.. code-block:: bash
pip install git+https://github.com/TutteInstitute/evoc.git
----------
References
----------
The algorithm implemented in EVōC is not published anywhere at this time. If you would like
to cite something in reference to EVōC, I would encourage you to cite the PLSCAN paper
on which the cluster extraction in EVōC is based:
Please cite:
D.M. Bot, L. McInnes, J. Aerts.
*Persistent Multiscale Density-based Clustering.*
In: arXiv preprint arXiv:2512.16558, 2025.
https://arxiv.org/abs/2512.16558.
-------
License
-------
EVōC is BSD (2-clause) licensed. See the LICENSE file for details.
------------
Contributing
------------
Contributions are more than welcome! If you have ideas for features of projects please get in touch. Everything from
code to notebooks to examples and documentation are all *equally valuable* so please don't feel you can't contribute.
To contribute please `fork the project `_ make your
changes and submit a pull request. We will do our best to work through any issues with you and get your code merged in.
================================================
FILE: azure-pipelines.yml
================================================
# Trigger a build when there is a push to the main branch or a tag starts with release-
trigger:
branches:
include:
- main
tags:
include:
- release-*
# Trigger a build when there is a pull request to the main branch
# Ignore PRs that are just updating the docs
pr:
branches:
include:
- main
exclude:
- doc/*
- README.rst
parameters:
- name: includeReleaseCandidates
displayName: "Allow pre-release dependencies"
type: boolean
default: false
variables:
triggeredByPullRequest: $[eq(variables['Build.Reason'], 'PullRequest')]
stages:
- stage: RunAllTests
displayName: Run test suite
jobs:
- job: run_platform_tests
strategy:
matrix:
mac_py310:
imageName: 'macOS-latest'
python.version: '3.10'
linux_py310:
imageName: 'ubuntu-latest'
python.version: '3.10'
windows_py310:
imageName: 'windows-latest'
python.version: '3.10'
mac_py311:
imageName: 'macOS-latest'
python.version: '3.11'
linux_py311:
imageName: 'ubuntu-latest'
python.version: '3.11'
windows_py311:
imageName: 'windows-latest'
python.version: '3.11'
mac_py312:
imageName: 'macOS-latest'
python.version: '3.12'
linux_py312:
imageName: 'ubuntu-latest'
python.version: '3.12'
windows_py312:
imageName: 'windows-latest'
python.version: '3.12'
mac_py313:
imageName: 'macOS-latest'
python.version: '3.13'
linux_py313:
imageName: 'ubuntu-latest'
python.version: '3.13'
windows_py313:
imageName: 'windows-latest'
python.version: '3.13'
mac_py314:
imageName: 'macOS-latest'
python.version: '3.14'
linux_py314:
imageName: 'ubuntu-latest'
python.version: '3.14'
windows_py314:
imageName: 'windows-latest'
python.version: '3.14'
pool:
vmImage: $(imageName)
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '$(python.version)'
displayName: 'Use Python $(python.version)'
- script: |
python -m pip install --upgrade pip
displayName: 'Upgrade pip'
# 1. Install the full LLVM package only if the OS is macOS
- script: |
brew install llvm@20
# Homebrew formula names can change, so we ensure it links correctly if necessary
brew link --force --overwrite llvm@20
displayName: 'Install LLVM via Homebrew (macOS only)'
condition: eq(variables['Agent.OS'], 'Darwin')
# 2. Find the Homebrew install path and set the environment variable only on macOS
- script: |
# Determine the LLVM install prefix dynamically
LLVM_PREFIX=$(brew --prefix llvm@20)
# Set the LLVM_CONFIG environment variable used by llvmlite's build script
echo "##vso[task.setvariable variable=LLVM_CONFIG]$LLVM_PREFIX/bin/llvm-config"
echo "LLVM_CONFIG set to: $LLVM_CONFIG"
# Also set CMAKE_PREFIX_PATH in case other dependencies need it
echo "##vso[task.setvariable variable=CMAKE_PREFIX_PATH]$LLVM_PREFIX/lib/cmake"
displayName: 'Configure LLVM Environment Variables (macOS only)'
condition: eq(variables['Agent.OS'], 'Darwin')
- script: |
python -m pip install -U uv
uv sync --group cicd
env:
# Ensure that the LLVM_CONFIG environment variable is available during installation
LLVM_CONFIG: $(LLVM_CONFIG)
CMAKE_PREFIX_PATH: $(CMAKE_PREFIX_PATH)
displayName: 'Install package and dependencies'
- script: |
uv run pytest evoc/tests --show-capture=no -v --disable-warnings --junitxml=junit/test-results.xml --cov=evoc/ --cov-report=xml --cov-report=html
displayName: 'Run tests'
condition: ne(variables['Agent.OS'], 'Darwin')
- script: |
uv run pytest evoc/tests -v --capture=tee-sys --disable-warnings --junitxml=junit/test-results.xml --cov=evoc/ --cov-report=xml --cov-report=html
displayName: 'Run tests'
condition: eq(variables['Agent.OS'], 'Darwin')
- task: PublishTestResults@2
inputs:
testResultsFiles: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
testRunTitle: '$(Agent.OS) - $(Build.BuildNumber)[$(Agent.JobName)] - Python $(python.version)'
condition: succeededOrFailed()
- stage: BuildPublishArtifact
dependsOn: RunAllTests
condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/release-'), eq(variables.triggeredByPullRequest, false))
jobs:
- job: BuildArtifacts
displayName: Build source dists and wheels
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.13'
displayName: 'Use Python 3.13'
- script: |
python -m pip install --upgrade pip
python -m pip install -U uv
uv sync
displayName: 'Install dependencies'
- script: |
uv build --no-sources --sdist --wheel
displayName: 'Build package'
- bash: |
export PACKAGE_VERSION="$(uv version --short)"
echo "Package Version: ${PACKAGE_VERSION}"
echo "##vso[task.setvariable variable=packageVersionFormatted;]release-${PACKAGE_VERSION}"
displayName: 'Get package version'
- script: |
echo "Version in git tag $(Build.SourceBranchName) does not match version derived from setup.py $(packageVersionFormatted)"
exit 1
displayName: Raise error if version doesnt match tag
condition: and(succeeded(), ne(variables['Build.SourceBranchName'], variables['packageVersionFormatted']))
- task: DownloadSecureFile@1
name: PYPIRC_CONFIG
displayName: 'Download pypirc'
inputs:
secureFile: 'pypirc'
- script: |
uvx twine check dist/*
uvx twine upload --repository pypi --config-file $(PYPIRC_CONFIG.secureFilePath) dist/*
displayName: 'Upload to PyPI'
condition: and(succeeded(), eq(variables['Build.SourceBranchName'], variables['packageVersionFormatted']))
================================================
FILE: doc/Makefile
================================================
# Makefile for Sphinx documentation
# You can set these variables from the command line
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help"
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx-build
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
# Custom targets
clean:
@$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
html:
@$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O)
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
livehtml:
sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O)
linkcheck:
@$(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS) $(O)
@echo "Link check complete; look for any errors in the above output or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
@$(SPHINXBUILD) -b doctest "$(SOURCEDIR)" "$(BUILDDIR)/doctest" $(SPHINXOPTS) $(O)
@echo "Testing of doctests in the sources finished, look at the results in $(BUILDDIR)/doctest/output.txt."
================================================
FILE: doc/README.md
================================================
# EVoC Documentation
This directory contains the Sphinx documentation for EVoC.
## Structure
```
doc/
├── build/ # Generated documentation (HTML, PDF, etc.)
├── source/ # Source files for documentation
│ ├── _static/ # Static files (CSS, images, etc.)
│ ├── _templates/ # Custom Sphinx templates
│ ├── api/ # API documentation files
│ ├── notebooks/ # Jupyter notebook examples
│ ├── tutorials/ # Step-by-step tutorials
│ ├── conf.py # Sphinx configuration
│ ├── index.rst # Main documentation page
│ └── *.rst # Other documentation pages
├── requirements.txt # Documentation dependencies
├── Makefile # Build commands (Unix)
├── build_docs.sh # Automated build script (Unix)
├── build_docs.bat # Automated build script (Windows)
└── README.md # This file
```
## Building the Documentation
### Prerequisites
1. Python 3.8 or later
2. Git (for development installation)
### Quick Build
**Unix/macOS:**
```bash
cd doc
./build_docs.sh
```
**Windows:**
```cmd
cd doc
build_docs.bat
```
### Manual Build
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Install EVoC in development mode:
```bash
pip install -e ../..
```
3. Build documentation:
```bash
make html
```
4. Open `build/html/index.html` in your browser
### Advanced Options
**Clean build:**
```bash
make clean html
```
**Check links:**
```bash
make linkcheck
```
**Run doctests:**
```bash
make doctest
```
**Live reload during development:**
```bash
pip install sphinx-autobuild
make livehtml
```
## Features
- **Sphinx RTD Theme**: Professional appearance matching ReadTheDocs
- **Numpydoc**: Automatic parsing of NumPy-style docstrings
- **Nbsphinx**: Integration of Jupyter notebooks as documentation
- **Autodoc**: Automatic API documentation generation
- **ReadTheDocs Ready**: Configured for automatic deployment
## Adding Content
### New Documentation Pages
1. Create `.rst` files in `source/`
2. Add them to the `toctree` in `index.rst`
3. Rebuild documentation
### Jupyter Notebooks
1. Add `.ipynb` files to `source/notebooks/`
2. Add them to `source/notebooks/index.rst`
3. Notebooks are automatically converted during build
### API Documentation
API documentation is automatically generated from docstrings. To add new modules:
1. Add the module to `source/api/index.rst`
2. Create a dedicated `.rst` file if needed
3. Rebuild documentation
## ReadTheDocs Integration
This documentation is configured for ReadTheDocs deployment:
- Configuration: `.readthedocs.yaml` in project root
- Requirements: `doc/requirements.txt`
- Python version: 3.11 (configurable in `.readthedocs.yaml`)
## Troubleshooting
**Import errors during build:**
- Ensure EVoC is installed in development mode: `pip install -e ../..`
- Check that all dependencies are installed: `pip install -r requirements.txt`
**Missing modules in API docs:**
- Verify the module paths in `source/api/index.rst`
- Check that modules are importable from the documentation directory
**Notebook execution errors:**
- Notebooks are not executed by default (`nbsphinx_execute = 'never'`)
- To execute notebooks during build, change to `nbsphinx_execute = 'always'` in `conf.py`
**Theme or styling issues:**
- Check `source/_static/custom.css` for customizations
- Verify `sphinx_rtd_theme` is installed
## Contributing
When adding new documentation:
1. Follow reStructuredText formatting
2. Use NumPy-style docstrings for API documentation
3. Include code examples where appropriate
4. Test build locally before submitting
5. Keep notebook outputs clear for examples
For more details, see the main EVoC contributing guidelines.
================================================
FILE: doc/build_docs.bat
================================================
@echo off
REM Documentation build script for EVoC (Windows)
echo Building EVoC Documentation
echo ==========================
REM Check if we're in the right directory
if not exist "source\conf.py" (
echo Error: Run this script from the doc directory
exit /b 1
)
REM Check if virtual environment exists, create if needed
if not exist "venv" (
echo Creating virtual environment...
python -m venv venv
)
REM Activate virtual environment
call venv\Scripts\activate.bat
REM Install requirements
echo Installing documentation requirements...
pip install -r requirements.txt
REM Install EVoC in development mode
echo Installing EVoC in development mode...
pip install -e ..\..
REM Clean previous build
echo Cleaning previous build...
make clean
REM Build HTML documentation
echo Building HTML documentation...
make html
if %ERRORLEVEL% equ 0 (
echo Documentation built successfully!
echo Open build\html\index.html in your browser to view
) else (
echo Build failed with errors
exit /b 1
)
echo Build complete!
================================================
FILE: doc/build_docs.sh
================================================
#!/bin/bash
# Documentation build script for EVoC
set -e # Exit on any error
echo "Building EVoC Documentation"
echo "=========================="
# Check if we're in the right directory
if [ ! -f "source/conf.py" ]; then
echo "Error: Run this script from the doc directory"
exit 1
fi
# Check if virtual environment exists, create if needed
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Install requirements
echo "Installing documentation requirements..."
pip install -r requirements.txt
# Install EVoC in development mode
echo "Installing EVoC in development mode..."
pip install -e ../.
# Clean previous build
echo "Cleaning previous build..."
make clean
# Build HTML documentation
echo "Building HTML documentation..."
make html
# Check for warnings
if [ $? -eq 0 ]; then
echo "Documentation built successfully!"
echo "Open build/html/index.html in your browser to view"
else
echo "Build failed with errors"
exit 1
fi
# Optional: Run link check
if [ "$1" = "--check-links" ]; then
echo "Checking links..."
make linkcheck
fi
# Optional: Run doctests
if [ "$1" = "--test" ]; then
echo "Running doctests..."
make doctest
fi
echo "Build complete!"
================================================
FILE: doc/requirements.txt
================================================
# Sphinx documentation requirements
sphinx>=7.0.0
sphinx-rtd-theme>=2.0.0
numpydoc>=1.6.0
nbsphinx>=0.9.0
ipython>=8.0.0
ipykernel>=6.0.0
jupyter>=1.0.0
matplotlib>=3.5.0
numpy>=1.21.0
scipy>=1.7.0
scikit-learn>=1.0.0
pandas>=1.3.0
numba>=0.56.0
# Optional but recommended for better notebook handling
pandoc>=2.0
ipywidgets>=8.0.0
================================================
FILE: doc/source/_static/custom.css
================================================
/* Custom CSS for EVoC documentation */
/* Improve code block styling */
.highlight {
background-color: #f8f8f8;
border: 1px solid #e1e4e5;
border-radius: 4px;
padding: 8px;
margin: 12px 0;
}
/* Better parameter list formatting */
.field-list {
margin: 1em 0;
}
.field-list dt {
font-weight: bold;
color: #2980b9;
}
/* Notebook cell styling */
.nbinput .highlight,
.nboutput .highlight {
border-left: 4px solid #1f8c8c;
margin: 0.5em 0;
}
/* API documentation improvements */
.py.class dt {
background-color: #f0f0f0;
border-left: 4px solid #3498db;
padding: 8px;
margin-top: 20px;
}
.py.method dt {
background-color: #f9f9f9;
border-left: 3px solid #95a5a6;
padding: 6px;
margin-top: 15px;
}
/* Parameter tables */
.docutils th {
background-color: #34495e;
color: white;
padding: 8px;
}
.docutils td {
padding: 6px 8px;
border-bottom: 1px solid #ecf0f1;
}
/* Admonition improvements */
.admonition {
margin: 20px 0;
padding: 15px;
border-radius: 6px;
}
.admonition.note {
background-color: #e8f4fd;
border-left: 4px solid #3498db;
}
.admonition.warning {
background-color: #fdf4e8;
border-left: 4px solid #f39c12;
}
/* Code span improvements */
code.literal {
background-color: #f1f2f3;
color: #e74c3c;
padding: 2px 4px;
border-radius: 3px;
font-size: 90%;
}
/* Sidebar improvements */
.wy-nav-side {
background: linear-gradient(180deg, #2c3e50 0%, #34495e 100%);
}
/* Footer customization */
.rst-footer-buttons {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e1e4e5;
}
================================================
FILE: doc/source/api/evoc.cluster_trees.rst
================================================
evoc.cluster_trees
==================
.. automodule:: evoc.cluster_trees
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: doc/source/api/evoc.clustering.rst
================================================
evoc.clustering
===============
.. automodule:: evoc.clustering
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: doc/source/api/evoc.clustering_utilities.rst
================================================
evoc.clustering_utilities
=========================
.. automodule:: evoc.clustering_utilities
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: doc/source/api/generated/evoc.EVoC.rst
================================================
evoc.EVoC
=========
.. currentmodule:: evoc
.. autoclass:: EVoC
.. automethod:: __init__
.. rubric:: Methods
.. autosummary::
~EVoC.__init__
~EVoC.fit
~EVoC.fit_predict
~EVoC.get_metadata_routing
~EVoC.get_params
~EVoC.set_params
.. rubric:: Attributes
.. autosummary::
~EVoC.cluster_tree_
================================================
FILE: doc/source/api/generated/evoc.boruvka.parallel_boruvka.rst
================================================
evoc.boruvka.parallel\_boruvka
==============================
.. currentmodule:: evoc.boruvka
.. autofunction:: parallel_boruvka
================================================
FILE: doc/source/api/generated/evoc.cluster_trees.condense_tree.rst
================================================
evoc.cluster\_trees.condense\_tree
==================================
.. currentmodule:: evoc.cluster_trees
.. autofunction:: condense_tree
================================================
FILE: doc/source/api/generated/evoc.cluster_trees.extract_leaves.rst
================================================
evoc.cluster\_trees.extract\_leaves
===================================
.. currentmodule:: evoc.cluster_trees
.. autofunction:: extract_leaves
================================================
FILE: doc/source/api/generated/evoc.cluster_trees.get_cluster_label_vector.rst
================================================
evoc.cluster\_trees.get\_cluster\_label\_vector
===============================================
.. currentmodule:: evoc.cluster_trees
.. autofunction:: get_cluster_label_vector
================================================
FILE: doc/source/api/generated/evoc.cluster_trees.get_point_membership_strength_vector.rst
================================================
evoc.cluster\_trees.get\_point\_membership\_strength\_vector
============================================================
.. currentmodule:: evoc.cluster_trees
.. autofunction:: get_point_membership_strength_vector
================================================
FILE: doc/source/api/generated/evoc.cluster_trees.mst_to_linkage_tree.rst
================================================
evoc.cluster\_trees.mst\_to\_linkage\_tree
==========================================
.. currentmodule:: evoc.cluster_trees
.. autofunction:: mst_to_linkage_tree
================================================
FILE: doc/source/api/generated/evoc.clustering_utilities.binary_search_for_n_clusters.rst
================================================
evoc.clustering\_utilities.binary\_search\_for\_n\_clusters
===========================================================
.. currentmodule:: evoc.clustering_utilities
.. autofunction:: binary_search_for_n_clusters
================================================
FILE: doc/source/api/generated/evoc.clustering_utilities.build_cluster_tree.rst
================================================
evoc.clustering\_utilities.build\_cluster\_tree
===============================================
.. currentmodule:: evoc.clustering_utilities
.. autofunction:: build_cluster_tree
================================================
FILE: doc/source/api/generated/evoc.clustering_utilities.find_duplicates.rst
================================================
evoc.clustering\_utilities.find\_duplicates
===========================================
.. currentmodule:: evoc.clustering_utilities
.. autofunction:: find_duplicates
================================================
FILE: doc/source/api/generated/evoc.clustering_utilities.find_peaks.rst
================================================
evoc.clustering\_utilities.find\_peaks
======================================
.. currentmodule:: evoc.clustering_utilities
.. autofunction:: find_peaks
================================================
FILE: doc/source/api/generated/evoc.clustering_utilities.select_diverse_peaks.rst
================================================
evoc.clustering\_utilities.select\_diverse\_peaks
=================================================
.. currentmodule:: evoc.clustering_utilities
.. autofunction:: select_diverse_peaks
================================================
FILE: doc/source/api/generated/evoc.evoc_clusters.rst
================================================
evoc.evoc\_clusters
===================
.. currentmodule:: evoc
.. autofunction:: evoc_clusters
================================================
FILE: doc/source/api/generated/evoc.graph_construction.neighbor_graph_matrix.rst
================================================
evoc.graph\_construction.neighbor\_graph\_matrix
================================================
.. currentmodule:: evoc.graph_construction
.. autofunction:: neighbor_graph_matrix
================================================
FILE: doc/source/api/generated/evoc.knn_graph.knn_graph.rst
================================================
evoc.knn\_graph.knn\_graph
==========================
.. currentmodule:: evoc.knn_graph
.. autofunction:: knn_graph
================================================
FILE: doc/source/api/generated/evoc.label_propagation.label_propagation_init.rst
================================================
evoc.label\_propagation.label\_propagation\_init
================================================
.. currentmodule:: evoc.label_propagation
.. autofunction:: label_propagation_init
================================================
FILE: doc/source/api/generated/evoc.node_embedding.node_embedding.rst
================================================
evoc.node\_embedding.node\_embedding
====================================
.. currentmodule:: evoc.node_embedding
.. autofunction:: node_embedding
================================================
FILE: doc/source/api/generated/evoc.numba_kdtree.build_kdtree.rst
================================================
evoc.numba\_kdtree.build\_kdtree
================================
.. currentmodule:: evoc.numba_kdtree
.. autofunction:: build_kdtree
================================================
FILE: doc/source/api/index.rst
================================================
API Reference
=============
This section contains the complete API reference for EVoC.
Main Classes and Functions
--------------------------
.. currentmodule:: evoc
.. autosummary::
:toctree: generated/
:nosignatures:
EVoC
evoc_clusters
Core Clustering
---------------
.. autoclass:: EVoC
:members:
:inherited-members:
:show-inheritance:
.. autofunction:: evoc_clusters
Utility Functions
-----------------
.. currentmodule:: evoc.clustering_utilities
.. autosummary::
:toctree: generated/
:nosignatures:
find_peaks
binary_search_for_n_clusters
select_diverse_peaks
build_cluster_tree
find_duplicates
.. autofunction:: find_peaks
.. autofunction:: binary_search_for_n_clusters
.. autofunction:: select_diverse_peaks
.. autofunction:: build_cluster_tree
.. autofunction:: find_duplicates
Tree Operations
---------------
.. currentmodule:: evoc.cluster_trees
.. autosummary::
:toctree: generated/
:nosignatures:
mst_to_linkage_tree
condense_tree
extract_leaves
get_cluster_label_vector
get_point_membership_strength_vector
.. autofunction:: mst_to_linkage_tree
.. autofunction:: condense_tree
.. autofunction:: extract_leaves
.. autofunction:: get_cluster_label_vector
.. autofunction:: get_point_membership_strength_vector
Graph Construction
------------------
.. currentmodule:: evoc.knn_graph
.. autosummary::
:toctree: generated/
:nosignatures:
knn_graph
.. autofunction:: knn_graph
.. currentmodule:: evoc.graph_construction
.. autosummary::
:toctree: generated/
:nosignatures:
neighbor_graph_matrix
.. autofunction:: neighbor_graph_matrix
Node Embedding
--------------
.. currentmodule:: evoc.node_embedding
.. autosummary::
:toctree: generated/
:nosignatures:
node_embedding
.. autofunction:: node_embedding
.. currentmodule:: evoc.label_propagation
.. autosummary::
:toctree: generated/
:nosignatures:
label_propagation_init
.. autofunction:: label_propagation_init
Algorithm Components
--------------------
.. currentmodule:: evoc.boruvka
.. autosummary::
:toctree: generated/
:nosignatures:
parallel_boruvka
.. autofunction:: parallel_boruvka
.. currentmodule:: evoc.numba_kdtree
.. autosummary::
:toctree: generated/
:nosignatures:
build_kdtree
.. autofunction:: build_kdtree
================================================
FILE: doc/source/benchmarks.ipynb
================================================
{
"cells": [
{
"cell_type": "markdown",
"id": "93984a77-bccc-46fc-a7e1-4eb862d10f6e",
"metadata": {},
"source": [
"# Performance benchmarks\n",
"\n",
"This notebook provides performance benchmarks for EVoC in comparison to some commonly used options for embedding vector clustering. The goal of these benchmarks is not to be comprehensive, but rather to give a sense of where EVoC's strengths lie, particulary as compared with other standard options. The aim is to look at both clustering quality and compute time and focus primarily on real-world datasets. To ensure you can try running these benchmarks on your hardware we will provide all the code to run the benchmarks yourself. So to start let's get all the libraries we'll need, both to run the benchmarks and comparisons, and visualise the results."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "a7ae0fff-dec4-49b8-9063-aafef992c764",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:11.007014Z",
"iopub.status.busy": "2026-03-25T20:36:11.006894Z",
"iopub.status.idle": "2026-03-25T20:36:16.807511Z",
"shell.execute_reply": "2026-03-25T20:36:16.806699Z",
"shell.execute_reply.started": "2026-03-25T20:36:11.007000Z"
}
},
"outputs": [],
"source": [
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"import seaborn as sns\n",
"import time\n",
"import evoc\n",
"import umap\n",
"import hdbscan\n",
"import pandas as pd\n",
"import sklearn.cluster\n",
"import sklearn.metrics\n",
"import warnings\n",
"warnings.filterwarnings(\"ignore\")"
]
},
{
"cell_type": "markdown",
"id": "5a6ef4ce-bf5f-4e45-857b-bd60f6228fb4",
"metadata": {},
"source": [
"Now we will need come clustering alternatives to compare to. There are, of course, an endless array of clustering algorithms and implementations out there, but for the purposes of giving a basic comparison we will focus on the most common options for embedding vector clustering, such as those used in BERTopic. That means we'll need an implementation of UMAP + HDBSCAN for comparison, and we'll also compare with the standard workhorse: sklearn's KMeans. Sine we'll be benchmarking these we'll build a common calling format so we can build a benchmark harness around them easily. We'll start with UMAP + HDBSCAN which requires a little bit of work to glue together. "
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "9823192a-9d7e-4a1f-b912-84883f8273e8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.808127Z",
"iopub.status.busy": "2026-03-25T20:36:16.807874Z",
"iopub.status.idle": "2026-03-25T20:36:16.811159Z",
"shell.execute_reply": "2026-03-25T20:36:16.810725Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.808110Z"
}
},
"outputs": [],
"source": [
"def umap_hdbscan(\n",
" data,\n",
" metric=\"euclidean\",\n",
" n_neighbors=15,\n",
" n_components=2,\n",
" min_samples=5,\n",
" min_cluster_size=10,\n",
" min_dist=0.1,\n",
" cluster_selection_method=\"eom\",\n",
" n_epochs=None,\n",
" negative_sample_rate=5,\n",
"):\n",
" embedding = umap.UMAP(\n",
" metric=metric,\n",
" n_neighbors=n_neighbors,\n",
" n_components=n_components,\n",
" min_dist=min_dist,\n",
" n_epochs=n_epochs,\n",
" negative_sample_rate=negative_sample_rate,\n",
" n_jobs=8,\n",
" ).fit_transform(data)\n",
" clustering = hdbscan.HDBSCAN(\n",
" min_samples=min_samples,\n",
" min_cluster_size=min_cluster_size,\n",
" cluster_selection_method=cluster_selection_method,\n",
" ).fit_predict(embedding)\n",
" return clustering"
]
},
{
"cell_type": "markdown",
"id": "f9985b20-ab5a-4001-b834-fea29fb90796",
"metadata": {},
"source": [
"Next up is KMeans. We don't need much of a wrapper here -- we can call on sklearn's implementation fairly directly."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "e3730e80-f580-41d1-a103-9f3d2440bf15",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.811753Z",
"iopub.status.busy": "2026-03-25T20:36:16.811617Z",
"iopub.status.idle": "2026-03-25T20:36:16.825930Z",
"shell.execute_reply": "2026-03-25T20:36:16.825498Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.811741Z"
}
},
"outputs": [],
"source": [
"def kmeans(data, n_clusters=10, kmeans_algorithm=\"lloyd\"):\n",
" return sklearn.cluster.KMeans(\n",
" n_clusters=n_clusters, \n",
" n_init=\"auto\", \n",
" algorithm=kmeans_algorithm\n",
" ).fit_predict(\n",
" data\n",
" )"
]
},
{
"cell_type": "markdown",
"id": "e4e47881-987e-4944-a116-3acd14437b5a",
"metadata": {},
"source": [
"Lastly we need an EVoC function that works with the same pattern. We'll even be sure to use the reproducible (fixed ``random_state``) code-path that is a little slower, but vary the seed to ensure we get variation in results. Since EVoC can provide a few layers of clustering, we'll give it a little bonus by selecting out the best cluster layer compared to a target clustering. In practice EVoC usually selects a very good cluster layer, but for some of the datasets we'll be using the class labels are not exactly the most natural clustering, so we'll let the function choose a different layer in those cases. Note that we are not tuning any other EVoC parameters to optimize the results, just using defaults and selecting among the layers produced (usally 2-5 total layers for any of these datasets, so few enough that you could easily look througbn them by hand). In contrast we did spend time tuning parameters for the other algorithms to try to produce the best accuracy/quality scores we could. Sometimes this involved non-obvious choices of parameters."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "75a6f8bf-71eb-4053-943c-48d3f75d7052",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.826407Z",
"iopub.status.busy": "2026-03-25T20:36:16.826278Z",
"iopub.status.idle": "2026-03-25T20:36:16.837721Z",
"shell.execute_reply": "2026-03-25T20:36:16.837043Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.826395Z"
}
},
"outputs": [],
"source": [
"def EVoC(data, test_target=None, random_state=None):\n",
" if random_state is None:\n",
" random_state = np.random.randint(65536)\n",
" cls = evoc.EVoC(random_state=random_state).fit(data)\n",
" if test_target is None:\n",
" return cls.labels_\n",
" result = np.full(data.shape[0], -1)\n",
" best_ari = 0.0\n",
" for labels in cls.cluster_layers_:\n",
" ari = sklearn.metrics.adjusted_rand_score(\n",
" test_target[labels >= 0], labels[labels >= 0]\n",
" )\n",
" if ari > best_ari:\n",
" best_ari = ari\n",
" result = labels\n",
" return result"
]
},
{
"cell_type": "markdown",
"id": "5d644252-ab58-4de7-9ca2-b4db23bc38af",
"metadata": {},
"source": [
"Now we need a test harness. To asses the quality of a clustering we'll use datasets that come equipped with class labels, and specifically datasets where there is good reason to expect that the clusters should align reasonably with the class labels. This let's us use robust scores such as Adjusted Rand Index (ARI) and Adjusted Mutual Information (AMI) that compare a clustering against ground-truth labels. Since both UMAP + HDBSCAN and EVoC have a notion of noise points we'll exclude those from the ARI and AMI computations (as they don't make sense as a single class, and confuse things). But we will keep track of how much of the data is clustered, and also track a \"clustering score\" that is a weighted geometric mean of the ARI and the amount of data clustered (weighted 2:1 in favour of ARI accuracy, so we mostly care about being right, but also need to cluster a reasonable amount of data). We will also keep track of how long it takes to run any of these methods."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "8c2abe82-1c74-45fa-b6cc-5c36647b8c74",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.838269Z",
"iopub.status.busy": "2026-03-25T20:36:16.838138Z",
"iopub.status.idle": "2026-03-25T20:36:16.850442Z",
"shell.execute_reply": "2026-03-25T20:36:16.849785Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.838257Z"
}
},
"outputs": [],
"source": [
"def score_clustering(data, target, clustering_function, n_runs=16, **kwargs):\n",
" result = np.zeros((n_runs, 5), dtype=np.float32)\n",
" for i in range(n_runs):\n",
" start_time = time.time()\n",
" clustering = clustering_function(data, **kwargs)\n",
" result[i, 0] = time.time() - start_time\n",
" result[i, 1] = sklearn.metrics.adjusted_rand_score(\n",
" target[clustering >= 0], clustering[clustering >= 0]\n",
" )\n",
" result[i, 2] = sklearn.metrics.adjusted_mutual_info_score(\n",
" target[clustering >= 0], clustering[clustering >= 0]\n",
" )\n",
" result[i, 3] = np.sum(clustering >= 0) / clustering.shape[0]\n",
" result[i, 4] = np.cbrt((result[i, 1] ** 2) * result[i, 3])\n",
"\n",
" result = pd.DataFrame(\n",
" result,\n",
" columns=(\n",
" \"Elapsed time\",\n",
" \"Adjusted Rand Index\",\n",
" \"Adjusted Mutual Information\",\n",
" \"Proportion clustered\",\n",
" \"Clustering Score\",\n",
" ),\n",
" )\n",
" result[\"algorithm\"] = clustering_function.__name__.replace(\"_\", \"\\n\")\n",
" result = result.melt(\n",
" id_vars=[\"algorithm\"],\n",
" value_vars=[\n",
" \"Elapsed time\",\n",
" \"Adjusted Rand Index\",\n",
" \"Adjusted Mutual Information\",\n",
" \"Proportion clustered\",\n",
" \"Clustering Score\",\n",
" ],\n",
" var_name=\"measure\",\n",
" )\n",
" return result"
]
},
{
"cell_type": "markdown",
"id": "af6fd5e6-2f44-478b-9a34-b1e935b141be",
"metadata": {},
"source": [
"Lastly we'll need a simple function to run all our clustering algorithm benchmarks for each method across a given dataset and provide a nice table of results that we can easily pass to seaborn for plotting."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "40c1aa9a-fb16-473a-ad2c-02ac59bad712",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.850912Z",
"iopub.status.busy": "2026-03-25T20:36:16.850774Z",
"iopub.status.idle": "2026-03-25T20:36:16.862692Z",
"shell.execute_reply": "2026-03-25T20:36:16.862360Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.850901Z"
}
},
"outputs": [],
"source": [
"def run_dataset_benchmarks(data, target, n_runs, kmeans_kwargs, umap_hdbscan_kwargs):\n",
" \"\"\"Score all three algorithms on a dataset and return combined results.\"\"\"\n",
" kmeans_results = score_clustering(\n",
" data, target, kmeans, n_runs=n_runs, **kmeans_kwargs\n",
" )\n",
" umap_results = score_clustering(\n",
" data, target, umap_hdbscan, n_runs=n_runs, **umap_hdbscan_kwargs\n",
" )\n",
" evoc_results = score_clustering(\n",
" data, target, EVoC, test_target=target, n_runs=n_runs\n",
" )\n",
" return pd.concat(\n",
" [kmeans_results, umap_results, evoc_results.assign(algorithm=\"EVoC\")],\n",
" ignore_index=True,\n",
" )"
]
},
{
"cell_type": "markdown",
"id": "9b36e588-23ee-42bf-b931-f2a797250807",
"metadata": {},
"source": [
"## Image embeddings\n",
"\n",
"Let's start by looking at image embeddings. For a \"real-world\" dataset we'll use the tried and true CIFAR-100 dataset. The CIFAR-100 dataset is a popular computer vision benchmark containing 60,000 32x32 color images across 100 classes. In our case we don't need to worry about the images themselves, but rather their embeddings. We have built a dataset with embedding vectors generated by CLIP and provided it on Huggingface datasets so we don't have to worry about the time/cost of embedding all the images. If you wish you can generate your own embeddings, potentially with other models such as SigLIP. Let's pull that data down. "
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "2bb0aaaa-35f0-4588-ad58-466f0cae8ceb",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:16.863280Z",
"iopub.status.busy": "2026-03-25T20:36:16.863153Z",
"iopub.status.idle": "2026-03-25T20:36:19.367650Z",
"shell.execute_reply": "2026-03-25T20:36:19.367253Z",
"shell.execute_reply.started": "2026-03-25T20:36:16.863268Z"
}
},
"outputs": [],
"source": [
"from datasets import load_dataset"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "2046299e-7e86-490b-805c-4ccc92088877",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:19.368857Z",
"iopub.status.busy": "2026-03-25T20:36:19.368555Z",
"iopub.status.idle": "2026-03-25T20:36:49.643729Z",
"shell.execute_reply": "2026-03-25T20:36:49.642787Z",
"shell.execute_reply.started": "2026-03-25T20:36:19.368842Z"
}
},
"outputs": [],
"source": [
"ds_cifar = load_dataset(\"lmcinnes/evoc_bench_cifar100\")\n",
"cifar_data = np.asarray(ds_cifar[\"train\"][\"embedding\"])\n",
"cifar_target = np.asarray(ds_cifar[\"train\"][\"target\"])"
]
},
{
"cell_type": "markdown",
"id": "44a1a510-281d-493c-b9fb-38e89d68cd99",
"metadata": {},
"source": [
"Now we just need to run the benchmarks. Here we had to rune parameters a little. Oddly enough KMeans actually works better if you ask for 125 clusters (instead of the 100 classes that exist) because with more clusters it does a better job of breaking up some of the more easily confused classes that otherwise notably drag down ARI scores. For UMAP + HDBSCAN we need to manage pick the right parameters; in general UMAP doesn't need too much tuning for this, but HDBSCAN works best with leaf clustering and a carefully well-chosen ``min_cluster_size``. A little experimentation finds around 120 works well for this data."
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "3fa73eb5-68c7-49be-80a7-bd3527264111",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:36:49.644685Z",
"iopub.status.busy": "2026-03-25T20:36:49.644502Z",
"iopub.status.idle": "2026-03-25T20:42:55.905457Z",
"shell.execute_reply": "2026-03-25T20:42:55.904368Z",
"shell.execute_reply.started": "2026-03-25T20:36:49.644670Z"
}
},
"outputs": [],
"source": [
"cifar_results = run_dataset_benchmarks(\n",
" cifar_data, \n",
" cifar_target, \n",
" n_runs=16, \n",
" kmeans_kwargs={\"n_clusters\":125}, \n",
" umap_hdbscan_kwargs={\n",
" \"min_samples\":5,\n",
" \"min_cluster_size\":120, \n",
" \"metric\":\"cosine\", \n",
" \"cluster_selection_method\":\"leaf\"\n",
" }\n",
")"
]
},
{
"cell_type": "markdown",
"id": "83150948-cc2d-429f-a913-eac665250739",
"metadata": {},
"source": [
"Before we look at quality, let's compare how long these different approaches took to run:"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "9233e308-90ed-4c57-8a1e-738a7a7e898f",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:42:55.906750Z",
"iopub.status.busy": "2026-03-25T20:42:55.906539Z",
"iopub.status.idle": "2026-03-25T20:42:56.203555Z",
"shell.execute_reply": "2026-03-25T20:42:56.202959Z",
"shell.execute_reply.started": "2026-03-25T20:42:55.906732Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAMVCAYAAADqKmIJAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAUCJJREFUeJzt3Qd8VeX9P/Bv2IIkgjIFEUVw4Bb33triat216q9a66pWrf5s3Vat2mqHdbV1VH9WW1et1r1FoEq1WkUEREFFUEYCyCb/13P4JyZwY9FDchPyfr9e95Wc55x77pNzc5PzOc84JZWVlZUBAACQQ4s8TwYAABAsAACA5UKLBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYANEolJSXx4IMPxopYz+eeey573vTp0+utXgANTbAAoMEdc8wx2Yn1ko+99957hXs3dt555zj99NNrlW277bYxceLEKCsrK1q9AJa3Vst9jwA0CZWVlbFw4cJo1ao4/wpSiLj11ltrlbVt2zaagzZt2kT37t2LXQ2A5UqLBcDXuAJ96qmnZlehO3XqFN26dYubb745Zs2aFccee2x07Ngx1l577Xj00UdrPe/tt9+OfffdN1ZeeeXsOUcddVR89tln1esfe+yx2H777WOVVVaJVVddNb75zW/G2LFjq9fPmzcvTjnllOjRo0e0a9cu1lxzzbjiiiuyde+//352xf/111+v3j51s0llqdtNze43jz/+eGyxxRbZSfyLL76YBYyrrroq1lprrVhppZVi4403jnvvvbfefy/S66eT65qPdDzrcs4550T//v2jffv2WV3PP//8mD9/fvX6iy66KDbZZJO46aabonfv3tl2Bx98cK3uRukYbLnlltGhQ4fsOG+33XbxwQcfVK//+9//Hptvvnl2fNNrXHzxxbFgwYLq9aNHj44dd9wxW7/++uvHk08++V9bZp5//vn49a9/Xd0qk96rJbtC3XbbbVl9Hn744RgwYEBW929/+9vZ79Ttt9+evdfp2KTfuxQGa/5OnH322bH66qtnP9NWW21V/X4DNDTBAuBrSCd7q622Wvzzn//MTvZOPPHE7CQ2dXH517/+FXvttVcWHD7//PNs+9TtZaeddspOfF999dUsREyaNCkOOeSQ6n2mk8gzzjgjXnnllXj66aejRYsWceCBB8aiRYuy9b/5zW/ioYceir/85S8xatSouPPOO7MTzq8qnYimQDJy5MjYaKON4rzzzstaDm644YZ466234kc/+lF85zvfyU6I6/KDH/wgC0hf9hg/fvxy/d1KgS2dgKeAlk7Uf//738e1115ba5sxY8ZkxycFhHSMU9A6+eSTs3UpIBxwwAHZ+/DGG2/E0KFD4/vf/352gp+kwJV+7h/+8IfZa6SAkl7vsssuy9an9+Gggw6Kli1bxrBhw+LGG2/Mws6XSfXcZptt4vjjj89+B9IjhZ5C0u9Keo/vvvvurO4pIKTX+8c//pE97rjjjizA1gx9KcgOGTIke076mdLvYGoJSgEIoMFVAvCV7LTTTpXbb7999fKCBQsqO3ToUHnUUUdVl02cOLEy/YkdOnRotnz++edX7rnnnrX2M2HChGybUaNGFXydyZMnZ+vffPPNbPnUU0+t3HXXXSsXLVq01Lbjxo3Ltn3ttdeqy6ZNm5aVPfvss9ly+pqWH3zwweptZs6cWdmuXbvKl19+udb+vve971UefvjhdR6DSZMmVY4ePfpLH/Pnz6/z+UcffXRly5Yts+NW83HJJZdUb5Pq+sADD9S5j6uuuqpy8803r16+8MILs32m41rl0UcfrWzRokX2fkyZMiXb53PPPVdwfzvssEPl5ZdfXqvsjjvuqOzRo0f2/eOPP15w//+tnun35bTTTqtVVvVepPcoufXWW7PlMWPGVG9zwgknVLZv375yxowZ1WV77bVXVp6kbUtKSio/+uijWvvebbfdKs8999w66wNQX4yxAPga0pX+KukKduq6tOGGG1aXpa5OyeTJk7OvI0aMiGeffTa7kr+k1N0pdfFJX1P3nnQ1PHWRqmqpSFf+Bw4cmHWr2WOPPbKuMumqdOoqteeee37luqduUFXSlfk5c+Zk+60pdbHZdNNN69xH165ds0ceu+yyS9ZKUlPnzp3r3D5dqf/Vr36VtUrMnDkza4EoLS2ttc0aa6wRvXr1ql5OrQXpOKYWntRSkY5hak1KP+/uu++etRilrmVV71FqLapqoUhSt6N0fFJrQmrhKbT/5SV1f0pd6Gr+DqUWqZq/M6ms6ncqtYyl/JV+d2qaO3du9vsI0NAEC4CvoXXr1rWWU3eammVV3WuqwkH6Onjw4LjyyiuX2lfViW1an7rJpC4+PXv2zJ6TAkU6yU8222yzGDduXDZ246mnnspOitPJcTrhTt2mksUX+herOf6gptQXv0pV/R555JGsn/6yDqROXaFSV6wvk0JLOhGvS6pHv379YlmksHXYYYdlYx5SMEizKaXuP7/85S+/9HlV70PV19TlK3V1Sl2N7rnnnqwbWBonsfXWW2fHIu0/dT9aUhpTUfPYLrn/hvidqiqr+TuVQm0KROlrTYUCLEB9EywAGkAKBffdd192BbrQLExTpkzJroinfv077LBDVvbSSy8ttV26Qn/ooYdmjzS4N7VcTJ06Nbp06ZKtT334q1oaag7krksagJwCRGoVSVf0l9Ull1wSZ5111pduk8LR8pLGEfTp0yd++tOfVpfVHHRdJf0cH3/8cfVrp3EUKXTVvKqfjk96nHvuuVmLw1133ZUFi/QepZaNusJOOlaF9r8sM0DVHHC9vKSfIe03tWBU/c4AFJNgAdAA0gDi1BJx+OGHx49//ONs4Hfq0pOuuqfyNONP6r6SBuemFox0Avu///u/tfaRBiqndWkAeDpZ/utf/5rNpJRmE0rL6eT45z//eRZeUleqdDV+WQZEp4CQBmynK+BpVqqKiop4+eWXs6veRx99dL11hUpddj755JNaZSl0pWOzpHSyn45JOl6DBg3KWlgeeOCBgi0Lqc6/+MUvsp8jtU6klp10nFJrTzq+++23XxYMUoh4991347vf/W723AsuuCDrXpZajdIg6HRM04DoN998M372s59lrUOpG1raPrWUpP3XDDp1Se/H8OHDs9mg0jH9su5eX0UKS0ceeWR1fVLQSO/7M888k3XLSzOQATQks0IBNIB0IpuuuqcrzKkrT+ridNppp2VdetIJbHqkk+bUrSWtSyf6V199da19pJPS1JUqjZFIJ9fpRDXNFlTVDeqWW27Juj+l9Wnf6WR4WVx66aXZSXWaKWq99dbL6pdmVerbt2/Up9QdKQWlmo8UbArZf//9s2OSpttNwSoFnzQepVAASV2Z0kl1Gn+SjuX1119fPYbhnXfeiW9961vZSXmaESrt74QTTsjWp587Tfeaukal45uC2jXXXJO1lCTpOKcwkwJRmrL2uOOOqzUeoy4puKWuSqnFI7UsLc/ZslLXrhQszjzzzCz0pNCUQkxdM08B1KeSNIK7Xl8BABpAuo/Fgw8+uExdwABY/rRYAAAAuQkWAABAbrpC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AOQmWAAAAIIFAABQfFosAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgKYdLK644ooYNGhQdOzYMbp27RoHHHBAjBo1qtY2lZWVcdFFF0XPnj1jpZVWip133jneeuutotUZAABoZMHi+eefj5NPPjmGDRsWTz75ZCxYsCD23HPPmDVrVvU2V111VVxzzTVx3XXXxSuvvBLdu3ePPfbYI2bMmFHMqgMAADWUVKYmgUbi008/zVouUuDYcccds9aK1FJx+umnxznnnJNtM3fu3OjWrVtceeWVccIJJyy1j7Q+PapUVFRE7969o7y8PEpLSxv05wEAgOaiUY2xSCf/SefOnbOv48aNi08++SRrxajStm3b2GmnneLll1+us3tVWVlZ9SOFCgAAoJkEi9Q6ccYZZ8T2228fAwcOzMpSqEhSC0VNablq3ZLOPffcLKBUPSZMmNAAtQcAgOatVTQSp5xySrzxxhvx0ksvLbWupKRkqRCyZFnNFo30AAAAmlmLxamnnhoPPfRQPPvss9GrV6/q8jRQO1mydWLy5MlLtWIAAADNNFiklofUUnH//ffHM888E3379q21Pi2ncJFmjKoyb968bHD3tttuW4QaAwAAja4rVJpq9q677oq//e1v2b0sqlom0qDrdM+K1N0pzQh1+eWXxzrrrJM90vft27ePI444ophVBwAAGst0s3WNk7j11lvjmGOOyb5P1bv44ovjpptuimnTpsVWW20Vv/vd76oHeP83abrZFFRMNwsAAM3kPhb1QbAAAIBmMngbAABo2gQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAABAsAAAAIpPiwUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAUL8m/jti7DMRc2csvW7h/IiKjyMWzPMuADRxrYpdAQBWUNMnRNzznYiJry9ebrNyxO4XRWx5/OLll38b8dKvIj7/LGKlzhHbnhqxwxlFrTIAX59gAUD9uPfYL0JFMm9mxD/Oiui+UcSn70Q8cd4X62ZPjXj64oi2Hb8IHgA0KbpCAbD8fToq4sNXCq97/c6I4TcWXjfsBu8GQBMlWACw/M2p+JJ15RHlHxVeV1FHOQCNnmABwPLXY+OI9qsVXtdv94heWxRe12uQdwOgiRIsAFj+WrWJ2PeqiJKWtcvX3CFio0MjdvlJRKt2tde1bLu4HIAmqaSysrIyVmAVFRVRVlYW5eXlUVpaWuzqADQvk0dGvHZnxOdTI9baOWKDAxeHjuSTNyOG/i5i8tsRqw2I2ObkiJ6bFLvGAHxNggUAxbNgbsSMiRErd49ovUQLBgBNiulmASiOF36x+F4Wc6ZHtCuL2PqkiJ3OiSgp8Y4ANEHGWADQ8F75Y8Qzly4OFVUzRT13Rd3T0ALQ6AkWADS8f95cuHz4TQ1dEwCKHSzGjBkTjz/+eMyePTtbXsHHgAOwPFVMLFyexlsA0DyCxZQpU2L33XeP/v37x7777hsTJy7+J3DcccfFmWeeWR91BGBFs8ZWhct711EOwIoXLH70ox9Fq1atYvz48dG+ffvq8kMPPTQee+yx5V0/AFZEO58b0fqL/yGZVitF7HpesWoEQEPPCvXEE09kXaB69epVq3ydddaJDz74IG99AGgOVt8s4vhnI4al+1iM/P/3sTgpotsGxa4ZAA0VLGbNmlWrpaLKZ599Fm3btv269QCguem6bsR+vy12LQAoVleoHXfcMf70pz9VL5eUlMSiRYvi6quvjl122WV51QuAFd3MTyOevzri3v+JeO7KiBmTil0jABryzttvv/127LzzzrH55pvHM888E/vtt1+89dZbMXXq1BgyZEisvfba0ZhUVFREWVlZlJeXR2lpabGrA0AyZWzELXtHzJr8xfFov2rEsY9GdBngGAE0hxaL9ddfP954443YcsstY4899si6Rh100EHx2muvNbpQAUAjlW6OVzNUJJ9PiXj6kmLVCICGbrFoarRYADRCP+/zxV23a0ozRf3UvSwAmsXg7RdeeOG/jsEAgC/VrqxwsGi3igMH0FyCRRpfsaQ0gLvKwoUL89cKgBXbZt9d3B1qqfKjilEbAIoxxmLatGm1HpMnT85ujDdo0KDsHhcA8F9td3rEZkdHlLRcvJy+bvKdiB1/7OABNPcxFqmLVLor94gRI77Sc9I0tek5EydOjAceeCAOOOCA6vXHHHNM3H777bWes9VWW8WwYcOW+TWMsQBoxComRkwZHbFqv4jSnsWuDQAN2RWqLl26dIlRo0Z9peekGaU23njjOPbYY+Nb3/pWwW323nvvuPXWW6uX27Rpk7uuADQSpT0WPwBofsEiTTVbU2rwSK0NP//5z7OQ8FXss88+2ePLpLt5d+/efZn3OXfu3OxRs8UCAABoZMFik002yQZrL9mDauutt45bbrkllrfnnnsuunbtGqusskrstNNOcdlll2XLdbniiivi4osvXu71AAAAluMYiw8++KDWcosWLbJuUO3atctXkZKSpcZY3HPPPbHyyitHnz59Yty4cXH++efHggULsjEZqSVjWVssevfu7c7bAADQmFos0kl+Qzn00EOrvx84cGBsscUW2es/8sgj2d2+C0mBo67QAQAAFDFY/OY3v1nmHf7whz+M+tKjR48sWIwePbreXgMAAKinYHHttdcuc3em+gwWU6ZMiQkTJmQBAwAAaGLBIo1vqA8zZ86MMWPG1Hqd119/PTp37pw9Lrroomwa2hQk3n///fjJT34Sq622Whx44IH1Uh8AAKDI97H4Ol599dXYZZddqpfPOOOM7OvRRx8dN9xwQ7z55pvxpz/9KaZPn56Fi7RtGtDdsWPHItYaAABYLnfe/vDDD+Ohhx6K8ePHx7x582qtu+aaa6IxcedtAABohC0WTz/9dOy3337Rt2/f7E7babam1E0p5ZPNNtusfmoJAAA0ai2+6hPOPffcOPPMM+M///lPdu+K++67LxtQnW5ed/DBB9dPLQEAgBUrWIwcOTIbA5G0atUqZs+end3E7pJLLokrr7yyPuoIAACsaMGiQ4cO1Xe27tmzZ4wdO7Z63WeffbZ8awfAiuE/90fc+a2IP+4V8cIvIubOKHaNACj2GIutt946hgwZEuuvv3584xvfyLpFpdmb7r///mwdANTy9CURL/7yi+UJwyJG/j3ifx6PaN3OwQJorsEizfqU7j+RpPtMpO/TFLD9+vVb5hvpAdBMzJwcMeQ3S5dPfD3irfsjNjmiGLUCoDEEi0svvTS+853vZLNAtW/fPq6//vr6qBcAK4KPRkQsml943fhhggVAcx5jMWXKlKwLVK9evbJuUOlO2QBQUGnPL1+3aFHE51MjFi10AAGaW7BIN8b75JNP4sILL4wRI0bE5ptvno23uPzyy7P7WQBAtR4bR/QuMP6udYeIkpYR124QcVXfiGvWjxh2gwMH0NzuvL3kXbj//Oc/xy233BKjR4+OBQsWRGPiztsARTbz04i/nxbx7qMRlYsiuq4f0X+viJcKjMsb/OuIzY8pRi0BaOgxFjXNnz8/Xn311Rg+fHjWWtGtW7e89QFgRbNyl4jD71rc5WnerIhVekfcsH3hbYf+TrAAaC5doZJnn302jj/++CxIpJvldezYMf7+979nd+AGgILad14cKpLpHxTeZvp4Bw+gubRYpEHbaQD3XnvtFTfddFMMHjw42rUzDzkAX0HPTSPGPV+4HIDmESwuuOCCOPjgg6NTp071UyMAVnw7nxsxfmjEwnlflLVovbgcgOY5eLuxM3gboJH6cETEy7+OmPxOxGrrRGx3WkTvLYtdKwC+JsECAAAozuBtAACAmgQLAIpn7szFXaHmzvAuADTn+1gAwNeShvc9e3nEsOsj5s1cfCfurb4fsduFESUlDipAE6TFAoCG98+bI164anGoSObPWnwn7qHXeTcAmijBAoCG98/ff7VyABo9wQKAhjdzch3lkxq6JgAsJ4IFAA2vzzZ1lG/b0DUBYDkRLABoeLv8NKJNx9plaQD3rud5NwCaKDfIA6A4poyNGH5jxOSREav1j9j6xMV34AagSRIsAACA3HSFAgAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcWuXfBQAswz0rJv0novNaEd03dLgAVkCCBQD1Z+GCiIdOjfj3nyOicnHZ2rtGHHx7RLtSRx5gBaIrFAD1Z9j1Ef++64tQkYx9JuLJ8x11gBWMYAFA/claKgp44y8RlTXCBgBNnq5QANSfebMKly+YE7FoYUT5+IhPR0Ws1j9i1bW9EwBNmGABQP3pv1fEP29eunytXSIe/EHEm/f+/25SJRHr7x9x0M0Rrdp6RwCaIF2hAKg/O54dseo6tcs6dIno1Dfizb/WGHtRGfH2gxHPXeHdAGiiSiorV+xOrhUVFVFWVhbl5eVRWmoGEoAGN+/zxSHikzcWTze78eERN+20uBvUklbuFnHWu94kgCZIVygA6leb9hGbH127bN6MwtvOnendAGiidIUCoOH126Nw+Tp1lAPQ6AkWADS8Xc+LKF29dtnK3SN2u8C7AdBEGWMBQHHMnr74PheT345YbUDEJkdEtO/s3QBoogQLAAAgN12hAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAojtnTIia9FTHvc+8AwAqgVbErAMAKYOp7EU9eEPHuExFt2kdsfHjErucv/n5JC+dHPHp2xGt3RiycF9G2LGL70yN2OOO/v04KIq/fFTFvZsQ6e0b03yeihWtkAI1BSWVlZWWswCoqKqKsrCzKy8ujtLS02NUBWPHMnh5x/dYRMybWLk8n/UfcvfT2KYAM+fXS5QfeHLHxoXW/TgoiD50aUbnoi7L1Bkcc/CfhAqARcJkHgHz+fffSoSJ599GIySNrly1aGPHqbYX38+ofF38d+XDE3UdG/OmAiGE3RMyfHTF3RsSj/1s7VGTb/n3x6wBQdLpCAZDPlNF1r/vs3Yiu632xvGBuxNzywtvOnBTxzM8iXrj6i7L3no14+6GI7X4YMW9G4eeNfiJi3W983doDsJwIFgDk03X9ute16xTx99MjPng5YuWuEYOOi+i5acTHry297eqbR7z0q6XLx78csdaOdb9G245fs+IALE+6QgGQz0aHRnRac+nyNMbi/uMiRtwa8dmoiPdfjPjr0RE9N4to2bb2th26RPTZLmLR/MKvMfPTiFX7LV1e0mLxQHEAik6wACCftitHHPtoxKZHRXToujhk7HxuxGrrLO7etKS37o/4n8ciNj82oteWEet+M+Lg2yK6b1j3a3TsEXHo/0V0XvuLsjYdIwb/JqLbBt5BgEZAVygA8ivtGbH/dbXLbt+v7vtXpJaGOdMjPvzn4rJ3Hl4cMLoNjJj0n9rbt24fscnhEWW9Ik4dETF+2OLpZtfYWjcogEZEsACgfqyyRuHylm0i3rw34q0HapencLHF9yLarxox7vnFZamF4pvXLA4VSUlJRJ9tvGMAjZBgAUD92PL7EW/cs/gmeDWlMRFvP1j4OWn62B+Pjij/KGL+54vHVaQwAUCjZ4wFAPWjx0YRh/95cfemqjERW58Use/Vi7syFTJv1uKvZasvHqMhVAA0GVosAKg//XZf/EjjKlp3iGjVZnH5OntFvFHgrtz99/JuADRRWiwAqH8rdfoiVCS7nhdR1rv2Nh17Rux2gXcDoIkqqaysrIwVWEVFRZSVlUV5eXmUlpYWuzoAVJlTsXgMxuS3I1YbELHxYRErreL4ADRRukIBUBztSiO2PN7RB1hB6AoFAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEDTDhYvvPBCDB48OHr27BklJSXx4IMP1lpfWVkZF110UbZ+pZVWip133jneeuutotUXAABohMFi1qxZsfHGG8d1111XcP1VV10V11xzTbb+lVdeie7du8cee+wRM2bMaPC6AgAAdSupTM0CjUBqsXjggQfigAMOyJZTtVJLxemnnx7nnHNOVjZ37tzo1q1bXHnllXHCCScs034rKiqirKwsysvLo7S0tF5/BgAAaK4a7RiLcePGxSeffBJ77rlndVnbtm1jp512ipdffrnO56XwkcJEzQcAANBMg0UKFUlqoagpLVetK+SKK67IWiiqHr179673ugIAQHPXaINFzS5SNaUuUkuW1XTuuedm3Z6qHhMmTGiAWgIAQPPWKhqpNFA7Sa0TPXr0qC6fPHnyUq0YNaXuUukBAAA0nEbbYtG3b98sXDz55JPVZfPmzYvnn38+tt1226LWDQAAaEQtFjNnzowxY8bUGrD9+uuvR+fOnWONNdbIZoS6/PLLY5111ske6fv27dvHEUccUcxqAwAAjSlYvPrqq7HLLrtUL59xxhnZ16OPPjpuu+22OPvss2P27Nlx0kknxbRp02KrrbaKJ554Ijp27FjEWgMAAI32Phb1xX0sAACgGY+xAAAAmg7BAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAILdW+XcBxbFg4aJ4auSkGPXJzFi7a4fYa4Pu0bqlrAwAUAyCBU3StFnz4og/DI+REyuqy/p1XTnuOn6r6NqxXVHrBgDQHLm8S5N0zZPv1goVyZjJM+PKR0cVrU4AAM2ZYEGT9NhbnxQu/8/EBq8LAACCBU1Uy5KSwuUtCpcDAFC/tFjQJA3euEcd5T0bvC4AAAgWNFGn7d4/tuzbuVbZJr1XibP3XrdodQIAaM5KKisrK2MFVlFREWVlZVFeXh6lpaXFrg7L2fD3psS7k2bE2l1Wjm3WXjVK6ugiBQBA/RIsAACA3IyxAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBgiZt+ufz4vUJ02PqrHnFrgoAQLPWqtgVgK9j0aLKuPwfI+OOYR/E3AWLok3LFnHooN5x0X4bRMsWJQ4qAEAD02JBk3TLkHHxh5fGZaEimbdwURYyrn92TLGrBgDQLAkWNEl3/XP8VyoHAKB+CRY0SXWNqZgy01gLAIBiECxokrZde9XC5f0KlwMAUL8EC5qkM/boH53at65V1rFdq/jxXgOKVicAgOaspLKysjJWYBUVFVFWVhbl5eVRWlpa7OqwHE0snx13DvsgRn0yI9busnJ8Z+s+0btze8cYAKAIBAsAACA3XaEAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3Frl3wUUx6y5C+KB1z6KdyfNiLW7rBwHbrZ6lLZr7e0AACgCwYImaWL57DjkpqExYers6rIbnx8b93x/m1hj1fZFrRsAQHOkKxRN0jVPvFsrVCQTy+fElY+/U7Q6AQA0Z4IFTdIz70wuWP70yEkNXhcAAAQLmqiV2rQsWN6+jd59AADFoMWCJumgTVf/SuUAANQvwYIm6eRd+8W+G3avVbbbul3jzD0HFK1OAADNWUllZWVlrMAqKiqirKwsysvLo7S0tNjVYTkbM3lm9XSzA7p3dHwBAIpEh3SatH5dV84eAAAUl65QAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYEGTNm/Bovh4+uyYu2BhsasCANCsuY8FTdbvX3gvbnh+bEydNS9Wad86jtu+b5yy6zrFrhYAQLMkWNAk3fPK+LjsHyOrl6d/Pj9+8cS70aFtqzh2u75FrRsAQHOkKxRN0q1D3v9K5QAA1C/BgiZpYvmcOspnN3hdAAAQLGiiNltjlTrKOzV4XQAAECxook7fvX+s1LplrbI2rVrEmXsOKFqdAACas5LKysrKWIFVVFREWVlZlJeXR2lpabGrw3I06pMZ8fsX34t3J82ItVbrEMftsFYMXL2s4LZvflgeYz+dGf27dYz1e/o9AABY3gQLVmiz5i6IH9w5Il4c/Vl12e7rdY3rjtgs2i3R4gEAwNdn8DYrhJETK+Lc+9+Mo/44PK567J2YVLF4cPcvnhhVK1QkT42cHL99ZnSRagoAsGLSYkGTMH/honjwtY/iuVGfRvs2LeNbm/eKrddaNVv34uhP43u3vRrzFi6q3r5Lx7bxwEnbxuDfvhTTPp+/1P5WX2WlGPK/uzbozwAAsCJzgzwavQULF8X/3PZKrZaHv474MM77xnrZuIrL//FOrVCRfDpjbtzw3NiYu6B2eZU58xfWe70BAJoTXaFo9J54e9JS3ZmqujlNmDor6wZVyLD3psRu63UruG6P9QuXAwDw9QgWNHovjVk6VCRz5i+Ktz6uiJXbFm54S92hztl7QNbtqaa+q3WIM/boXy91BQBornSFotHr3L5NnetSeDh0UO/440vjllp35FZ9olen9vHkGTvGQ69/HGMmz4wB3TvG4I17mhEKAGA5Eyxo9NJA7ZteGBvzF9a+5cqAbh1j5batY4OepbH3Bt3imXc+zcZalLZrFafs2i8LEEn7Nq3isC3XKFLtAQCaB7NC0SQ89p9P4vy//ScblJ0MXL00OrVvU2vsxXb9Vo2z9hwQ63YvjZXauEcFAEBD0mJBk7D3wO6x23pd482PyqNj21bxt9c/juueHVNrmyFjpsS63SfGpmt0Klo9AQCaK4O3aTJat2wRm63RKdbp1jEeeO2jgtvc/68PG7xeAAAIFjRRdd2HYrb7UwAAFIUWC5qk1C2qkD3W797gdQEAQLCgiTpzzwHRZ9X2tcrS/SrO3mtA0eoEANCcmRWKJuvzeQuy+1OMmjQj1uqychy46ep13iwPAID6JVgAAAC5GWMBAADkJliwwkg3zxvxwdSYOmtesasCANDs6JBOk7dg4aK44KG34i+vTIgFiyqjTcsWceTWa8T531g/WrQoKXb1AACaBS0WNHnpDtx3DR+fhYpk3sJFceuQ9+MPL71X7KoBADQbWixoMso/nx93Dv8gRnwwLbqVtosjt1ojBq5eFn/+5/iC29/9zwnx/R3XbvB6AgA0R4IFTUIaN3HQ9UPi/SmfV5f99dUJ8bsjN4tps+YXfM60z421AABoKLpC0STcOmRcrVCRpK5Plz0yMrbtt2rB52zXb7UGqh0AAI06WFx00UVRUlJS69G9e/diV4siGDp2SsHy8VM/j6O3XjNK29VufOvcoU38aI/+DVQ7AAAafVeoDTbYIJ566qnq5ZYtWxa1PhRHCgqFtG5ZEpv16RSPnb5j3Dnsgxj76cwY0L00G3+RxmEAANAwGn2waNWqlVYK4oit1ogn3p601JH4xoY9oqx96+xx9t7rOlIAAEXSqLtCJaNHj46ePXtG375947DDDov33vvyKUTnzp0bFRUVtR40fTsP6BqX7L9BlK3UOltOt6fYfp1VI80w+83fvhin3f1a/Oej8mJXEwCg2SqprKxcPPl/I/Too4/G559/Hv37949JkybFz372s3jnnXfirbfeilVXXbXOcRkXX3zxUuXl5eVRWlraALWmPs2ZvzBGT5oZk2fMiZPv+lfMmb+oel26Md4d39sytlqr8O8GAADNNFgsadasWbH22mvH2WefHWeccUadLRbpUSW1WPTu3VuwWMEcc+s/47lRny5VvmXfzvGXE7YpSp0AAJqzRj/GoqYOHTrEhhtumHWPqkvbtm2zByu218ZPL1j+eh3lAAA08zEWNaWWiJEjR0aPHj2KXRWKrEdZ4RmfeqxiJigAgGJo1MHirLPOiueffz7GjRsXw4cPj29/+9tZ16ajjz662FWjyP5nu74Fy4/dds0GrwsAAI28K9SHH34Yhx9+eHz22WfRpUuX2HrrrWPYsGHRp0+fYleNIjtkUO+omDM/bnx+bHw2c150at86jtthrTimjsABAED9alKDt7+O1MJRVlZm8PYKav7CRTFl5rzsBnptWjXqBjgAgBVao26xgP+mdcsW0b2O8RYAADQcwYImp3z2/LjmiVHx8BsTY1FlZew9sEf8eK8BWasFAADFoSsUTUrquXfQDS8vNd3sut07xiM/3CFapltyAwDQ4HRKp0kZMmZKwXtYvPPJjHhq5KSi1AkAAMGCJmb05Bl1rhszeWaD1gUAgC9osaBJWadrxzrX9eu6coPWBQCALxi8TaM29tOZccfQD+KDKbNi4Opl8Z2t1ohN11il4BiL3dbtWrR6AgA0dwZv02j9c9zU+O4tw2PO/EXVZV06to3bjx0Ud78yIZsVauGiRbF9v9Xi3H3Xi16d2he1vgAAzZmuUDRal/9jZK1QkXw6Y27c/vIHccn+A+PobdaMRYsiHnnzk9jr2hfiqsfeyWaNAgCg4QkWNEpz5i+M1ycsPftTMvS9KXHHsA/i2qfejRlzF2Rls+YtjOufGxu/f/G9Bq4pAACJYEGj1KZli+jYrvAQoHQjvDuGvl9w3Z+GflDPNQMAoBDBgkapRYuSOHzLNQquO3KrNWLyjLkF102uKFwOAED9EixotM7ac0AcNqh3tG65+G7aHdq0jB/t3j8O3qJ3bNGnc8HnDOrbqYFrCQBAYlYoGr0pM+fGx9PnRN8uHWLltou7R731cXkcetOwmPn/x1gkK7VuGf93/Fax2RrCBQBAQ3MfCxr9IO4hY6fE1Jlzo23rFtG/2+Ib5G3Qsyz+fur2ceuQcTHqkxmxVpeV43vbrxn9vuQGegAA1B/BgkbrPx+VxzG3vhKfzfxi3MRRW/eJSw8YWN1qMfy9qfHu5BnZNoPW7CRYAAAUiWBBo/Wje16vFSqSNM3sdv1WTb344pS7XqsuH/vprDjjL/+OFiUlccCmqxehtgAAzZvB2zRKqXvT6MkzC677+xsT48bnxxZcd8NzhcsBAKhfggWN0qIvuYN2urv2uM9mFVz33meFwwgAAPVLsKBRWrd7x1hrtQ4F1+0zsEe2vpD1epTWc80AAChEsKBRKikpiV8csnGUrdS6VvlBm64e39yoR5yya79o2aJkiedEnLrrOg1cUwAAEvexoFGbMWd+PPLGxJgya15s12+12KT3KtXrhoz5LH737Jh4d9KMWGu1lePEndeOXdbtWtT6AgA0V4IFAACQm65QAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkFur/LsAAKC5qaysjGETh8XY6WNjrVXWim16bBMlJSXFrhZFJFgAAPCVVMyriBOfOjHe+PSN6rKNVtsobtjjhihtU+poNlO6QgEA8JVc99p1tUJF8sZnb8Rv/vUbR7IZEywAABrItDnT4toR18Yhfz8kjnv8uPjHe/9oksf+8fcfL1j+xPtPRGP22ezPsseSFixaEC98+ELcP/r+eL/8/aLUbUWgKxQAQAOYOW9mfPfR78b7FV+cuA7/ZHi2fNImJ1V3MXr5o5ejdYvWsd3q20W7Vu1q7aN8bnnMnD8zenboWdTxDGl8RSGLYlE0RiksXDT0ohgxaUS2vHm3zePCbS6MvmV944OKD7JuXRNmTMjWlURJHDLgkPjpVj81ZuQrEiwAABrAA2MeqBUqqtz6n1vjyPWOjOc/fD4uHXppzFk4Jysva1sWv9jpF7F1j62zwJHWPfXBU7GgckH0Ke0TZw86O3bstWNR3rs9+uwRf3n3L0uV777G7vX6uq9Pfj3++J8/xphpY7JQcOzAY2NQ90HZunHl4+K3r/02hnw0JDq26RgHrnNgfH+j72ch6Pgnj49PZn1SvZ8UML7/5Pfj4QMfjvNeOq86VCSVURn3jLonCx/79N2nXn+eFY1gAQDQAN789M2C5SlIpJPhC4ZcEAsrF9ZqnTjjuTPiqW8/Fee+eG7WVadKusp++rOnx18H/zXWXmXtbN2f3v5TfDzz4xi46sA4bqPjon+n/tm2CxctzELL65++Ht3ad4tvrvXNLLTUNGv+rOxKffvW7ZfpZzl101Pj35/+O0ZNG1Vdll7vtM1OW6bnpzq9Nvm1LCRt1nWzaNOyTfW6UVNHZSf2kz+fHBt32ThrPUj1ffWTV7OAkLotJR/O/DBe/vjl+N1uv4t1O68bxzx2TEydMzVb9/mCz+PGf9+YHY/tV9++Vqioksrue/e+7LgUkrqpCRZfjWABANAAunfoXue6t6e+XStUVJkxb0Y8OObBWqGiyvxF8+Ped++N9VddP37y0k+qy9PV9xQk7tz3zujdsXfWzefVSa9Wr7/+9evj5j1vjg1W3SC7yn/F8CuyaWNblLSIXXrvEj/Z6ifRpX2XbNtJsybFI+MeyUJOajlJj9QFa5V2q8Q937wn7nrnrixgbNRlozhy3SOjZYuW1Sftf3jzDzF84vAsFHxrnW9lLQhVrQ4/fuHH1Sf7ndt1jku2vSR26r1TPD/h+Tj9udOrw0P6OVJLT/pZbnzjxuryKumYXf/v62PnXjtXh4qaHn7v4S897pM+n1TnunR8+WoECwCABnBw/4Pjz+/8ubqrU5V0UrxSq5XqfN6U2VPqXJdOzp+b8NxS5emKfeoyNKDTgFqhIqnqVnXLXrdkA8gnz55cfZL+1PinYvyM8XHv4HtjyMdDslaRuQvnZutv+c8tWReoq3e8OjvpPuv5s7IT/6rB3MM+Hha/3PmXMXfB3Djq0aNqtRKk8PHRzI/iuA2Pi9OePa1WCEjfn/n8mfHYQY/F1a9evVR4SEHpzrfvjLenvF3wGKTyXiv3KrhuUeWi6NS2U53HLwWpFNrGTB+z1Lpd19i1zudRmFmhAAAaQO/S3nH97tdnJ/tJGqC939r7xRU7XJGd4BbSpkWb7Ep/+1aFuyil1orUJaiuE+5CoSN5a8pb8dd3/1odKmp6d9q7WdesC1++sDpUVHnygyezxw3/vqE6VFR58aMXs9aQe0ffW7Dr0e1v3R6Pjnu0YMtCep3U/Sl18SoktXzUFR5Sq8w6ndYpuK5lScvYvc/usdsauy21btfeu8YmXTeJi7e9OBuTUVMau1LVwsKy02IBANBA0kDje/e7N2uFSK0UVWMaUkBIA5HTQO4qaczDOVueE7069oofbPyDuGbENbX2tVbZWnFI/0PijrfviGlzpxU84V4yGNTc96eff1pnPV+Z9Eo2xqGQZyY8k413KCR1PdqkyyYF16WWmrqCQ5JaKlq1aLVUi0VVd6k03uGcF89Zat0xGxwTO/feOWvVmDKnduvO4LUHZ12hrt7p6qzbWNV0uKnl5eABB2ffp25cjx70aBZ6Pp39aTZo213Evx7BAgCgga260qpLlZ2x+RmxZ5894+nxT2ctFXv33Tub+ShJoSN9nwYbT587PbbpuU02k1Qav5C+Xvf6dbX2lcZLHLX+UTFx5sTsav+Stu25bWzRfYu4/e3bC9YvDYauS9uWbesMLKkb1Oorr15wXapT6l5021u3FRxPktalFpSHxj601Lpv9/92NgYjve5Nb9yUdatKgeF7A78XB61zULbN7fvcXj0rVLr7d2pxSF2vqlqHDl/38OxRSDqOh617WJ0/M8tGsAAAaCQGrjYwexSSrsqnx5LSlKpp0HRquUjdjFJLximbnpINtE5TraY7YqdAkqZRTfqt0i8u2vai6Nq+a2zaddNsdqaa0tX81Dpw8xs3Fxx78I21vpGNXSgUAFL9Dh1waDYV7ewFs2ut22vNvbLWgRM3PnGpIJRaXjbssmE2w9WcBXOysR7pNVIXpZM2PikLFUkKCwf0OyDbd2rxqXkvjzQFb5qel+IpqazrDicriIqKiigrK4vy8vIoLS0tdnUAAOpFOhFPJ+WFpoxNXZDSAOo03eyW3besPiH/fP7n2diH1EqSuiGlQHHEekdkV/jTvSJOfvrk+HjWx9m2rUpaZSHmxE1OzMZQHPvYsbXGd6SWitv2vi1rSUhh5Rev/CILNWl8yP799s9aZKpu+JdmhnrkvUey6WbTvS/SzQBrSrNRpTtkr7XKWl86sJ3GRbAAAKCgNN5h6MdDs+5XKZB069Ctel0KJWlcQmrVSC0N+/bdd6lQk7Zp3bJ1FlRY8QkWAABAbqabBQAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3FrFCq6ysjL7WlFRUeyqAABAk9SxY8coKSlp3sFixowZ2dfevXsXuyoAANAklZeXR2lp6ZduU1JZdUl/BbVo0aL4+OOPlyll0TSl1qgUHCdMmPBff+GBxsdnGJo2n+HmoaMWi4gWLVpEr169iv1e0ABSqBAsoOnyGYamzWcYg7cBAIDcBAsAACA3wYImr23btnHhhRdmX4Gmx2cYmjafYZrN4G0AAKD+abEAAAByEywAAIDcBAsAACA3wYJ6s/POO8fpp5/uCAMANAOCBQBAM3bMMcdESUnJUo9dd901VltttfjZz35W8HlXXHFFtn7evHnL9DrPPvts7LvvvrHqqqtG+/btY/31148zzzwzPvroo+X8E1EsggUAQDO39957x8SJE2s97rvvvvjOd74Tt912WxSaRPTWW2+No446Ktq0afNf93/TTTfF7rvvHt27d8/2+/bbb8eNN94Y5eXl8ctf/rKefioammBBg3nssceirKws/vSnP2VXRw444IC4/PLLo1u3brHKKqvExRdfHAsWLIgf//jH0blz5+jVq1fccssttfaRrmoceuih0alTp+yKx/777x/vv/9+9fpXXnkl9thjj+wKSnqtnXbaKf71r3/V2ke6CvOHP/whDjzwwOyKyTrrrBMPPfRQ9fpp06bFkUceGV26dImVVlopW5/+eAK1rbnmmvGrX/2qVtkmm2wSF110UfVnLZ1MfPOb38w+a+utt14MHTo0xowZk3WV7NChQ2yzzTYxduzY6uen79PnOv1dWHnllWPQoEHx1FNPLfW6l156aRxxxBHZNj179ozf/va33h7IeS+KdNJf85H+137ve9/LPpcvvPBCre1ffPHFGD16dLZ+0aJFcckll2T/t9N+0t+B9D+/yocffhg//OEPs0f6v54+/+lzvOOOO2b/jy+44ALv3QpCsKBB3H333XHIIYdkoeK73/1uVvbMM8/Exx9/nP2xuuaaa7KTkXQCkv6QDR8+PH7wgx9kjwkTJmTbf/7557HLLrtkJxLpOS+99FL2fbrKUtUMO2PGjDj66KOzP3jDhg3LQkFqdk3lNaUQk+rzxhtvZOtTkJg6dWq27vzzz8+upDz66KMxcuTIuOGGG7KgAnx1KQCkz/zrr78e6667bhYGTjjhhDj33HPj1VdfzbY55ZRTqrefOXNm9plMYeK1116LvfbaKwYPHhzjx4+vtd+rr746Ntpoo+zCQdrXj370o3jyySe9RbCcbbjhhlnAX/ICWwoIW265ZQwcODB+/etfZ60Ov/jFL7L/q+lzu99++2XBI/nrX/+a/Z8+++yzC75GurjICiLdIA/qw0477VR52mmnVf7ud7+rLCsrq3zmmWeq1x199NGVffr0qVy4cGF12YABAyp32GGH6uUFCxZUdujQofLPf/5ztvzHP/4x22bRokXV28ydO7dypZVWqnz88ccL1iHto2PHjpV///vfq8vSr/15551XvTxz5szKkpKSykcffTRbHjx4cOWxxx673I4DrKjSZ/jaa6+tVbbxxhtXXnjhhQU/a0OHDs3K0me5Svp8t2vX7ktfZ/3116/87W9/W+t1995771rbHHrooZX77LNP7p8JmqP0P7lly5bZ/9yaj0suuSRbf8MNN2TLM2bMyJbT17R80003Zcs9e/asvOyyy2rtc9CgQZUnnXRS9v2JJ55YWVpa2uA/Fw1PiwX1KvWjTDNDPfHEE1lrQ00bbLBBtGjxxa9g6vqQroxUadmyZdbdafLkydnyiBEjsi4UHTt2zFoq0iN1mZozZ051V4q0bWrl6N+/f9YVKj3SFdAlr3amK51VUneMtM+q1znxxBOzFpbUlJuurrz88sv1dHRgxVfzs5Y+40nNz3kqS5/hioqKbHnWrFnZ5y4N6kxXMdPn/J133lnqM5y6UC25nFoYga8n/Y9OLYs1HyeffHK27vDDD8+6O91zzz3Zcvqarh0cdthh2Wc39T7Ybrvtau0vLVd9JtO2qWskK75Wxa4AK7Z0cp66KqQm1NSUWvMPS+vWrWttm9YVKkt/zJL0dfPNN4//+7//W+p10niIJI3d+PTTT7N+33369Mn6eqYTjiVnrPiy19lnn33igw8+iEceeSTrjrHbbrtlf1xTEy/whXRhYMkBnfPnz6/zs1b1+S9UVvX5S2OsHn/88ezz1q9fv2yc07e//e1lmnXGiQt8fekiW/rMFZIu0qXPYfpfnsZUpK9pubS0tPqiwJKfv5phIl3sS4O004DwHj16eJtWYFosqFdrr712Nr3c3/72tzj11FNz7WuzzTbL+mt27do1++NX85H+6CVpbEUaHJb6aKcWkRQsPvvss6/8WimopJBy5513ZiHl5ptvzlV3WBGlz0k6UaiSTjDGjRuXa5/pM5w+e2lyhdSykQaQ1pygoUoaQ7XkchrDAdSPFCiGDBkSDz/8cPY1LScpXKQJFNK4x5pSa3+asCFJISTNHHXVVVcV3Pf06dO9bSsILRbUu3SlIoWLNAtEq1atlppFZlmlAdZpwGaaMaZq9onUPeL+++/PrnKm5RQy7rjjjthiiy2yk5xUnq54fhVpdorUMpKCydy5c7M/olV/HIEvpDnu0zSUaXB1mnQhTXyQujDmkT7D6TOd9pmudqZ9VrVm1JRObNJJSppdLg3aToNDUysj8PWk/3effPJJrbL0P7tq8pI0y2L6fKbJGNLXNKNTlfS/9sILL8wuJqaeCqlFI3Wlquph0Lt377j22muziRrS/+a0jzQrVJotKk3qkro8mnJ2xSBY0CAGDBiQzQKVwsXXPfFI01Wm2aDOOeecOOigg7KZnlZfffWsq1K6YlI1S8X3v//92HTTTWONNdbIprM966yzvtLrpKsqaZaZdJU0hZIddtghG3MB1JY+J++99142m1tqNUwzQOVtsUgnH//zP/8T2267bXZCkz7vVV0tako31UrjrtIMb2mMVDopSTPRAF9Pmh52yW5K6X93GuNUJX02f/KTn2RBoqbUUyB9TtPnMo1XTGOk0jTuaWbGKieddFJ2oTF1c0wtkrNnz87CRfr7ccYZZ3jbVhAlaQR3sSsBAMsqnYykSSHSA4DGwxgLAAAgN8ECAADITVcoAAAgNy0WAABAboIFALWk2du+6sDoNDXsgw8+mH2fZlRLy2m6SQCaD8ECAADITbAAAAByEywAWEq62/XZZ58dnTt3ju7du8dFF11UvW706NHZXXfbtWuX3Qgr3fm6kHRjrXSju7RdupP9c889V71u2rRpceSRR0aXLl2yG1GmG2mlu/VWSXfkPeyww7LX79ChQ2yxxRYxfPjwbN3YsWNj//33j27dumV37B00aFA89dRTS93rIt0gM93QK91AL90w8+abb/ZOA9QjwQKApdx+++3ZCX06mb/qqqvikksuyQJEChzpzvctW7aMYcOGxY033pjdHbuQdHfedCfe1157LQsY++23X0yZMiVbd/7558fbb78djz76aIwcOTJuuOGG7E7bycyZM2OnnXaKjz/+OLt777///e8s5KTXrlq/7777ZmEi7TvdcXvw4MExfvz4Wq+f7sadAknaJt3198QTT6x1F2EAli/TzQKw1ODthQsXxosvvlhdtuWWW8auu+6aPdJJfRqg3atXr2zdY489Fvvss0888MADccABB2Tr+vbtGz//+c+rQ8eCBQuyslNPPTULCSlkpCBxyy23LHX0U8vCWWedle0ntVgsi9QikoLDKaecUt1iscMOO8Qdd9yRLVdWVmYtLxdffHH84Ac/8I4D1AMtFgAsZaONNqq13KNHj5g8eXLWupC6FVWFimSbbbYpeARrlrdq1SprPUjPT1IIuPvuu2OTTTbJgsbLL79cvW2aTWrTTTetM1TMmjUre07qhrXKKqtk3aFSS8SSLRY1f4Y0S1UKFulnAKB+CBYALKV169a1ltOJeeqKlK78LymtW1ZV26YWjg8++CCb1jZ1edptt92yVookjbn4MqmL1X333ReXXXZZ1qqSgsiGG24Y8+bNW6afAYD6IVgAsMxSK0FqGUhhoMrQoUMLbpvGYFRJXaFGjBgR6667bnVZGrh9zDHHxJ133hm/+tWvqgdXp5aGFBamTp1acL8pTKTnHXjggVmgSC0RqdsUAMUlWACwzHbfffcYMGBAfPe7380GVaeT/J/+9KcFt/3d736XjbtI3ZROPvnkbCaoNEtTcsEFF8Tf/va3GDNmTLz11lvx8MMPx3rrrZetO/zww7OwkMZrDBkyJN57772shaIqwPTr1y/uv//+LHykOhxxxBFaIgAaAcECgGX/p9GiRRYW5s6dmw3oPu6447IuSYWkwdtXXnllbLzxxlkASUGiauanNm3axLnnnpu1TqSpa9MsU2nMRdW6J554Irp27ZoNFE+tEmlfaZvk2muvjU6dOmUzTaXZoNKsUJtttpl3EaDIzAoFAADkpsUCAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAoAv9f7770dJSUm8/vrrjea1dt555zj99NPrvT4ALDvBAoBGo3fv3jFx4sQYOHBgtvzcc89lQWP69OnFrhoA/0Wr/7YBADSEefPmRZs2baJ79+4OOEATpMUCgHjsscdi++23j1VWWSVWXXXV+OY3vxljx46t88g89NBDsc4668RKK60Uu+yyS9x+++1LtSzcd999scEGG0Tbtm1jzTXXjF/+8pe19pHKfvazn8UxxxwTZWVlcfzxx9fqCpW+T/tOOnXqlJWnbassWrQozj777OjcuXMWRi666KJa+0/b33TTTdnP0r59+1hvvfVi6NChMWbMmKwrVYcOHWKbbbb50p8TgGUnWAAQs2bNijPOOCNeeeWVePrpp6NFixZx4IEHZifvS0on/N/+9rfjgAMOyALACSecED/96U9rbTNixIg45JBD4rDDDos333wzO+k///zz47bbbqu13dVXX511e0rbp/VLdotK4SQZNWpU1kXq17/+dfX6FGZSOBg+fHhcddVVcckll8STTz5Zax+XXnppfPe7383que6668YRRxyR1ffcc8+NV199NdvmlFNO8RsAsDxUAsASJk+eXJn+Rbz55puV48aNy75/7bXXsnXnnHNO5cCBA2tt/9Of/jTbZtq0adnyEUccUbnHHnvU2ubHP/5x5frrr1+93KdPn8oDDjig1jZLvtazzz5ba79Vdtppp8rtt9++VtmgQYOyulVJzzvvvPOql4cOHZqV/fGPf6wu+/Of/1zZrl077z/AcqDFAoCsO1C6mr/WWmtFaWlp9O3bNzsq48ePX+ropNaDQYMG1Srbcsstay2PHDkytttuu1plaXn06NGxcOHC6rItttjiax/9jTbaqNZyjx49YvLkyXVu061bt+zrhhtuWKtszpw5UVFR8bXrAcBiBm8DEIMHD866Hv3+97+Pnj17Zl2gUhelNKB6SakxII1fWLLsq26TpK5MX1fr1q1rLafXW7LrVs1tqupTqKxQly8AvhrBAqCZmzJlStbCkAY677DDDlnZSy+9VOf2aazCP/7xj1plVeMVqqy//vpL7ePll1+O/v37R8uWLZe5bmmWqKRmKwcAjZOuUADNXJpxKc0EdfPNN2czJj3zzDPZQO66pMHP77zzTpxzzjnx7rvvxl/+8pfqQdlVLQBnnnlmNgg8DZ5O26SB1tddd12cddZZX6luffr0yfb58MMPx6effhozZ87M+dMCUF8EC4BmLs0Adffdd2czM6XuTz/60Y+y2ZrqksZf3HvvvXH//fdnYxhuuOGG6lmh0tSyyWabbZYFjrTftM8LLrggm7Wp5nSxy2L11VePiy++OP73f/83Gw9hBieAxqskjeAudiUAaNouu+yyuPHGG2PChAnFrgoARWKMBQBf2fXXX5/NDJW6UA0ZMiRr4dCaANC8CRYAfGVp2th01+ypU6fGGmuskY2pSDedA6D50hUKAADIzeBtAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgMjr/wHvmRycAd54wAAAAABJRU5ErkJggg==",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" cifar_results[cifar_results.measure == \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" kind=\"swarm\", \n",
" col=\"measure\",\n",
" height=8,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "eface2b3-8171-4d9e-b1a1-585af3e31990",
"metadata": {},
"source": [
"As expected KMeans is noticeably quicker than UMAP + HDBSCAN. The one very long time for UMAP + HDBSCAN is due to numba's JIT compilation time (subsequent runs don't require compilation -- so we won't see this again). The surprise is that EVoC actually ran faster than KMeans here. Often KMeans is chosen simply based on runtime-constraints: it is usually faster than most other options you could pick. Here, however, we see that EVoC is not only competitive with KMeans, it's actually the faster option!\n",
"\n",
"How about the quality of the clusterings we get out?"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "6c0bcb69-473f-417f-b438-6fc5894d2159",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:42:56.204347Z",
"iopub.status.busy": "2026-03-25T20:42:56.204186Z",
"iopub.status.idle": "2026-03-25T20:42:56.875156Z",
"shell.execute_reply": "2026-03-25T20:42:56.874734Z",
"shell.execute_reply.started": "2026-03-25T20:42:56.204333Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAPeCAYAAAARWnkoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAA5a1JREFUeJzs3Qd4FNX6x/FfCkloCb33DiK9IygWiqIiXsCGipVrRdR7LyoW1D9W7KCoiFgAFVFUUMAGCKIgCALSe0+AhJ4Q9v+8J+6ym2yo2RT4fp5nDDttZ2bXPfPOOec9YR6PxyMAAAAAAJDlwrN+lwAAAAAAgKAbAAAAAIAQoqYbAAAAAIAQIegGAAAAACBECLoBAAAAAAgRgm4AAAAAAEKEoBsAAAAAgBAh6AYAAAAAIEQIugEAAAAACBGCbgBZ5vHHH1ejRo0yfZ2bnHfeeerXr59yqxtvvFHdunU7bd4HAPIiyrWcU6VKFb388stZus99+/bpyiuvVGxsrMLCwrRr1y7lBT/99FOeOl5kRNANIFMzZ85URESEOnfufFJX6YEHHtD333+fJwPlkSNHugLOO5UuXVqXXnqpFi1apNyAAhgAThzlWpjq1q2b4bp88sknrqyzQDevPMA+mQf777//vqZPn+6+B5s3b1ZcXJxym2DXtE2bNrn2eHF8CLqBXMrj8ejQoUM5egwjRozQ3XffrRkzZmjdunUnvH2hQoVUvHhx5VX2JNwKuU2bNumbb77R3r17dckllyg5OTmnDw0A8hzKtZxXsGBBbdu2TbNmzcpQ3leqVEmnu5UrV7qHDvXr11eZMmXcg4YTlZqaqsOHDys7RUVFnfTxIncg6MZpx54QWqBoTwmLFi3qaiiHDx/uAqY+ffqocOHCql69uiZNmhSw3eLFi3XxxRe7QNG26d27t+Lj433Lv/32W51zzjkqUqSICyS7du3qfry9LBC76667VLZsWcXExLinxYMHD3bL1qxZ434o58+f71vfmgjZPKux9K+5/O6779SsWTNFR0e7p7F2k/Lcc8+pWrVqyp8/vxo2bKjPPvss5NfRrpc9+f73v//tztVqftN75pln3LWya3rzzTfrwIEDR30KHezprTVttibOXkOHDlXNmjXdNbR9/+tf/3LzbZ2ff/5Zr7zyiq/22a7r8Xx2di7XX3+9W26fz4svvnhc18Dewwo528Y+k/vuu09r167V0qVLfesMGTJEZ599truRqVixou644w7t2bPHt9yum31n7HO1gt6OwVoOWDDvX4D379/f9936z3/+4z73E5FV73O075stu/DCC91+vdvZ99hu1B5++OETOl4Ax49yLWtQrkmRkZG65pprXJDttWHDBncPYvOP1f3IynD7Ph6tXPaWR/6++OKLgIDR7p8uv/xyV2ZbedW8eXNNnTr1lD5f7/G+8MILrty2cu7OO+9USkqKW27HbeX/tGnT3LF4z2Pnzp3uHsHuGQsUKKAuXbpo+fLlvv16z+frr79WvXr13P2Z3QvYfd5TTz3lu7+oXLmyvvzyS23fvt2dm82z+4M5c+b49pWQkKCrr75aFSpUcO9ly0ePHh1wDsGuabDWbePGjdNZZ53ljseOJf29jc37v//7P910003uPs3KarsfRs4g6MZpyZoPlShRQr/99psLwC1w7NGjh2ue88cff6hTp04uMLO+PcYCk3PPPdcFiPbjaAH21q1b1bNnz4DC2gKW33//3TWZDg8P1xVXXOF72vnqq69qwoQJLlC1oOzDDz884WZaxgIhC9aXLFmiBg0a6JFHHtF7772nYcOGuabNFvhdd9117kc5M3379nU/9kebjlVzPXbsWNWuXdtN9n52DP4Bmp3nY489pqefftpdMyvgLGA+Fbafe+65R4MGDXLX0D6H9u3bu2VWALVu3Vq33nqr+7xssiD3eD67Bx98UD/++KPGjx+vyZMnu8Jr7ty5J3RsVtB9/PHH7t/58uXzzbfvgX32f/31l/ve/fDDD+4z9GffM7sJ+OCDD1xhb9femt57WUFpN0Dvvvuua1WwY8cOd6wnKive52jfNyvw7Rzt/ys7Z+93zW6a7AELgNChXKNcy6pyzR6SWxnvvQeyoNIeptpv+YnIrFw+HvZw2h6WW6A9b948d19mXbhOplWdP7smFtDbX/t/xs7NW2nw+eefu2O1Y7ZjtdfeQNfuH+wezloA2L2OHZs3WDd2reze7J133nFlY6lSpdz8l156SW3btnXnYC3h7N7SgnArN+1+s0aNGu619/7JKieaNm3qAni7b7jtttvcNrNnzz6ha2qftX0frrrqKi1cuNCVwQMHDsxQQWLlvlUa2PFZpYDdD//999+ndI1xkjzAaebcc8/1nHPOOb7Xhw4d8hQsWNDTu3dv37zN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IegGAAAAACBECLoBAAAAAAgRgm4AAAAAAEKEoBsAAAAAgBAh6AYAAAAAIEQIugEAAAAACBGCbgAAAAAAQoSgGwAAAACAECHoBgAAAAAgRAi6AQAAAAAIEYJuAAAAAABO16B76NChqlq1qmJiYtS0aVNNnz79qOu/8cYbqlu3rvLnz6/atWtr1KhRActHjhypsLCwDNOBAwdCfCYAAAAAAASKVA4aO3as+vXr5wLvtm3b6q233lKXLl20ePFiVapUKcP6w4YN04ABA/T222+refPm+u2333TrrbeqaNGiuvTSS33rxcbGaunSpQHbWlAPAAAAAEB2CvN4PB7lkJYtW6pJkyYumPayWuxu3bpp8ODBGdZv06aNC86ff/553zwL2ufMmaMZM2b4arpt3q5du076uJKSkhQXF6fExEQXwAMAAMpQAADyVPPy5ORkzZ07Vx07dgyYb69nzpwZdJuDBw9mqLG2ZuZW452SkuKbt2fPHlWuXFkVKlRQ165dNW/evKMei+3XAm3/CQAAHBtlKAAAuTTojo+PV2pqqkqXLh0w315v2bIl6DadOnXSO++844J1q6C3Gu4RI0a4gNv2Z+rUqeNquydMmKDRo0e7IN1qx5cvX57psVitutVse6eKFStm8dkCAHB6ogwFACCXNi/ftGmTypcv72q1W7du7Zv/9NNP64MPPtDff/+dYZv9+/frzjvvdMvtsC1Av+666/Tcc89p69atKlWqVIZtDh8+7Jqwt2/fXq+++mqmT+lt8rKabgu8aV4OAMDRUYYCAJBLa7pLlCihiIiIDLXa27Zty1D77d+U3Gq29+3bpzVr1mjdunWqUqWKChcu7PYXTHh4uEu6drSa7ujoaNd3238CAADHRhkKAEAuDbqjoqLcEGFTpkwJmG+vLWHa0eTLl8/117agfcyYMa7ftgXXwViN+Pz581W2bNksPX4AAAAAAHL1kGH9+/dX79691axZM9fEfPjw4a72um/fvm65DQ+2ceNG31jcy5Ytc0nTLOv5zp07NWTIEP311196//33fft84okn1KpVK9WsWdM1E7cm5RZ02/jeAAAAAACcMUF3r169lJCQoEGDBmnz5s2qX7++Jk6c6DKPG5tnQbiXJV578cUX3RjcVtvdoUMH1yfcmph72VBht912m2u2bknRGjdurGnTpqlFixY5co4AAAAAgDNX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d97R4sWLValSpQzrz5gxQ+eee65eeuklXXrppdq4caP69u2rmjVravz48W6dWbNmuaD9ySefdIG2zX/00Ufdti1btjyu40pKSnIBfWJiomJjGQMTAIDjRRkKAMBJBN2hYkFwkyZNNGzYMN+8unXrqlu3bho8eHCG9a1G29a1Gmyv1157zdV8r1+/3r3u1auXK/AnTZrkW8dqw4sWLarRo0cf13FxwwAAwMmhDAUAIETjdJ+o5ORkzZ07Vx07dgyYb69nzpwZdJs2bdpow4YNmjhxosuevnXrVn322We65JJLfOtYTXf6fXbq1CnTfZqDBw+6mwT/CQAAHBtlKAAAWZxIzVjgO2HCBK1bt84Fz/6GDBlyXPuIj49XamqqSpcuHTDfXm/ZsiXToNv6dFtt9oEDB3To0CFddtllrrbby7Y9kX0aq1V/4oknjuu4AQAAZSgAACGr6f7+++9Vu3Zt1w/7xRdf1I8//qj33nvPjdE9f/78E92d6wfuz2qw08/zsr7elqjN+mhbLfm3337rhjCzft0nu08zYMAA13/bO3mbqgMAgKOjDAUAIItruq1wvf/++zVo0CAVLlzYZQovVaqUrr32Wtd3+niVKFFCERERGWqgt23blqGm2r9G2hKuPfjgg+51gwYNVLBgQZc47amnnlLZsmVVpkyZE9qniY6OdhMAADgxlKEAAGRxTfeSJUt0ww03uH9HRkZq//79KlSokAvCn3322ePeT1RUlJo2baopU6YEzLfX1ow8mH379ik8PPCQLXA33nxwrVu3zrDPyZMnZ7pPAAAAAAByTU231Sxb0hRTrlw5l0ncxtf29tM+Ef3793dDhjVr1swFy8OHD3f9xL3Nxa1W3YYFGzVqlHttw4TdeuutLoO5JUezsb379eunFi1auGMx9957r9q3b+8eAFx++eX68ssvNXXqVDdkGADg9LI3Za+iIqKULzxfTh8KAABA1gTdrVq10i+//KJ69eq5rOHW1HzhwoX6/PPP3bITYQnREhISXC25BdD169d3mckrV67slts8C8K9brzxRu3evVuvv/66e98iRYro/PPPD6hhtxrtMWPG6JFHHtHAgQNVvXp1Nx748Y7RDSCH7NshbfhdKlhCKt+UjwFHNXfrXL3w+wv6K+EvFYgsoG41uql/s/6KjohWSmqKRi0epUmrJ+nQ4UO6oPIFuqn+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bNmy7smrNWsePHiwrzC1p8nz58/3rW9Plm2ePWn2f+JsBWyzZs3cU24Ldq0wswLYnujmz59fDRs21GeffRby62jvX6ZMGfek/ZprrtG1117rq131NiEbMWKE74m8Hee6det0+eWXu2sYGxurnj17auvWrb59erd766233H4LFCigHj16BDxlP3z4sAYNGqQKFSq4/dr6du29vNfyk08+cZ+1XesPP/zQfbaJiYm+Gnp7r2DNy4/3GD/44AO3bVxcnK666irt3r37qNfrl19+0bnnnuvOyb53nTp10s6dO4+7ptq+V1Zbf6zvkv3bXHHFFW4/3tfmq6++UtOmTd029rk88cQTOnToUMD7vvnmm+78CxYsqKeeeuq4tlu+fLnat2/vltsNzpQpU456LQCcWSh3T90dd9zhfqMtmPzXv/6lWrVq6ayzzlL//v3166+/HndNtd1n2DwrK83atWtdbbmVS/a7b/ucOHGiW96hQwe3ji2zbayVmznWfUdm9yvpm5fb/rp166YXXnjBlWd272QPEPwflG/evFmXXHKJe5+qVavq448/Pma3MCuzHnroIXfPZuta+WX3fTfccINvnYMHD+o///mPu9ew46tZs6beffdd3/Kff/5ZLVq0cMvs2P73v/8FlHv2nbZy2K5/iRIldNFFFx3XvSKQHkE3Tmvvv/+++5G0wst+iP/973+74K5Nmzb6448/XEBkP5T79u3z/ehbwGSFxZw5c1yQZ4GYBWReFrTbj681V7bmU+Hh4S7wsSDRvPrqq5owYYILBpcuXeoCQf+A6HhZIWEB1pIlS9yT7kceecTVOFsN86JFi3TffffpuuuucwXG0Z4Ce584ZzZZ8HkirED0LyhXrFjhznXcuHG+hwlWuO7YscMdmwVm9lCiV69eAfvxbmeFpl1n29YKYa9XXnlFL774oiukFyxY4D6ryy67zAV+/qxp2T333OOu0wUXXOAKaAui7bO0yZq5pWc3EsdzjDbPgmJrqmaTrfvMM89kem3sHOwY7GZm1qxZmjFjhrvJsSf/J+No3yX7/hn7Tth5el/bzY99L+ya2E2BPdiwIP7pp58O2Pdjjz3mgu6FCxfqpptuOuZ29v3u3r27IiIi3I2fBe127QHAH+XuyZe7ViZZeWhloQXG6QXrOnW8bJ8WgE6bNs397j/77LPuWCwYtfLbWDlj5YmVv+Z47zvS368E8+OPP7oy1f7ad8TKF+8DZnP99ddr06ZNLpC347FKkm3bth31nKxCwB4cHO1huO13zJgxrjy147Oyy1sLv3HjRhc4N2/eXH/++ac7TwvIvQ+ivex4rdWDPVS3svF47hWBDDzAaercc8/1nHPOOb7Xhw4d8hQsWNDTu3dv37zNmzd77H+DWbNmudcDBw70dOzYMWA/69evd+ssXbo06Pts27bNLV+4cKF7fffdd3vOP/98z+HDhzOsu3r1arfuvHnzfPN27tzp5v3444/utf2111988YVvnT179nhiYmI8M2fODNjfzTff7Ln66qszvQZbt271LF++/KhTSkpKptvfcMMNnssvv9z3evbs2Z7ixYt7evbs6V4/9thjnnz58rlr4DV58mRPRESEZ926db55ixYtcuf022+/+bazdezaek2aNMkTHh7uPhNTrlw5z9NPPx1wPM2bN/fccccdAdfy5ZdfDljnvffe88TFxWU4l8qVK3teeumlEzrGAgUKeJKSknzrPPjgg56WLVtmer3ss2jbtu1Rv5P33nuv77W93/jx4wPWsWO3czjWdymz7du1a+f5v//7v4B5H3zwgads2bIB2/Xr1++Etvvuu++CfmbBjgHAmYly99TKXStj7Tf1888/P+a19v/t9d432P2El91n2DwrK83ZZ5/tefzxx4PuK9j2x3PfEex+xVt+NmzYMOBewspguw/z6tGjh6dXr17u30uWLHH7+f33333L7TrZPG+5HczPP//sqVChgrsPadasmSvXZsyY4Vtu9222jylTpgTd/qGHHvLUrl07oIx94403PIUKFfKkpqb6vtONGjUK2O5k7hUB+nTjtOb/xNVq6KxJ09lnn+2bZ02CjPdp6ty5c91T2GB9kewJrTXzsr8DBw50tX3WlMhbw21PruvXr++aUVnzo9q1a7v+0Nb8vGPHjid87NZUy8tqHg8cOOBr1uRlzY8bN26c6T5KlSrlplNhNbx2Pay5ldVwW+3oa6+95lteuXLlgP7S9iTZnpzb5GVNke0JvS2zJ8qmUqVKrum4V+vWrd21tCft1jTbnnhbvzZ/9tqeRmd2nY7X8R6j1SpbNwQva3p2tCfvVtNtLSmyysl8l+w7bLXe/jXbVtNu3x9r0WHXNth1O9Z2dl2CfWYA4I9y9+TL3bRYOq0LUFazVkzW2m/y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9VcdOHDAflkdNWqUDY5MQGS+yK5cuVL9+/e3y969e+3+p06dUtu2bW1gY45ZunSp/bf59czZffP48eO677777BfeP/74wwbMprumKU/OBPjmev755x+73QTZR44csdteeOEF28o0e/Zsbdq0SWPHjrVBPDI/Exyb99/atWtVpUoVGyg//PDDGjJkiFatWmX3+d///pe0/4kTJ+z7wwTaa9asUadOndS1a1eFhYW5nPftt99WrVq17A885lxPPfWU5s+fn+bPD2mvZs2a9seYlD/MmeC5UaNGqlGjht5//33bWv3OO+/YzxzzPrr55pttUG58//339jPs2Wef9fgY5kdJZG/UocgIqENxrVGHpjEHsqTWrVs7nnzyScdHH33kCA4Odvz6669J2+677z5HaGioIz4+Pqnsuuuuc7Rs2TJp/dy5c448efI4Jk2aZNcnTJhg90lISEjaJzY21hEYGOiYO3eux2sw58iXL59j5syZSWXmLTds2LCk9RMnTjh8fHwcs2fPtutdu3Z19O3b95rdB6QN83567733XMpq167teOmllzy+7itWrLBl5n3lZN5rAQEBF3ycatWqOcaMGePyuJ07d3bZp2fPno4uXbpc9XNCxmA+r/z8/OznUfJl+PDhdvvYsWPt+vHjx+26+WvWx40bZ9dLlCjheO2111zO2bBhQ8ejjz5q//3II484goKC0vx5IWOjDkVaog6Ft1CHZhy0dGdhZmyiyWA+b94820qdXPXq1eXre/7lN114zS9eTn5+frYLeWRkpF1fvXq17QqcL18+28JtFtMN/cyZM0ldgs2+pnW8cuXKtnu5WUxrZcqWSdMq6WS6FZtzOh/nkUcesa0KpguoaXlavny5l+4O0lry192834zk7zlTZt5PMTExdv3kyZP2PWASWpnWRvOe27x5s9v7yXRLT7luekkg6zCfX6aHRPLlscces9vuuusu24V8ypQpdt38Nb/z3Hnnnfa9ZHr0NG/e3OV8Zt35HjH7muEPQErUochIqENxpahDM4Yc6X0B8B4TuJout6brpemCmfyLZc6cOV32Nds8lZkvs4b5W79+fX3zzTduj2PGXxtmrPihQ4fs2N7Q0FA7hsQEQCmzB1/ocbp06aI9e/bol19+sd2Kr7/+evvl2nQNRcZlfsBJmczq7Nmzqb7uzveipzLne8HkF5g7d659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",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" cifar_results[cifar_results.measure != \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" col=\"measure\", \n",
" kind=\"swarm\", \n",
" col_wrap=2,\n",
" height=5,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "02e215d9-75bf-4395-9dfd-08b36606eff8",
"metadata": {},
"source": [
"Here we seem some of KMeans weakness -- it can run very fast, but the quality of the clusters is not always that great, particularly for very high dimensional data such as that produced by embedding models. The one upside of KMeans is that it does cluster all of your data, so the \"proportion clustered\" is perfect, and this significantly improves the clustering score since the other algoirithms only clustered around 80% of the data (but found much cleaner more accurate clusters by doing so). In contrast UMAP + HDBSCAN was much slower than KMeans, and we hope it managed to produce better clusters by doing so. And that is largely born out here. The ARI and AMI for UMAP + HDBSCAN are significantly better than KMeans, so we produced better clusters by expending more compute. Of course this did require not clustering 20% of the data -- but that 20% is likely the \"hard to classify\" samples that would simply make the ARI and AMI a lot worse if we tried to force them to be assigned somewhere. That means that on clustering score UMAP + HDBSCAN comes out a little ahead of KMeans. Last we have EVoC, which ran faster than KMeans and yet somehow also produced better clusters than UMAP + HDBSCAN. EVoC's edge in clustering quality over UMAP + HDBSCAN is not huge here, but it is relevant. The real strength of EVoC on this dataset is the raw speed with which it can produce those good results."
]
},
{
"cell_type": "markdown",
"id": "92b4f389-c9b3-4c57-ac92-c2be35f12133",
"metadata": {},
"source": [
"## Text embeddings\n",
"\n",
"For a text dataset we'll use the venerable 20-newsgroups dataset. The 20-newsgroups dataset is a dataset of NNTP newsgroup posts from the 1990s to twenty different newsgroup sections (think subreddits if you never used NNTP). As with the image dataset we have computed text embeddings ahead of time and put the dataset on Huggingface datasets for ease of access. This should be a challenging dataset to get good matches with the class labels on -- while the newsgroups are mostly distinct in topic, posts can easily run off-topic, be very short, or have more text from signature blocks that weren't properly stripped than actual content. That means the data can be very noisy, with a lot of posts that can be very hard to cluster with other posts from the same newsgroup. To make matters worse there are some overarching categories of newsgroups (there are multiple tech/computing newsgroups included, and multiple discussion groups for religion and politics that can tend to have overlaps). Natural clustering may not line up well with the full set of twenty distinct labels provided. "
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "71bad192-181f-4586-8241-d2674a5101ce",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:42:56.875997Z",
"iopub.status.busy": "2026-03-25T20:42:56.875696Z",
"iopub.status.idle": "2026-03-25T20:43:02.962330Z",
"shell.execute_reply": "2026-03-25T20:43:02.961712Z",
"shell.execute_reply.started": "2026-03-25T20:42:56.875982Z"
}
},
"outputs": [],
"source": [
"ds_news = load_dataset(\"lmcinnes/evoc_bench_20newsgroups\")\n",
"news_data = np.asarray(ds_news[\"train\"][\"embedding\"])\n",
"news_target = np.asarray(ds_news[\"train\"][\"target\"])"
]
},
{
"cell_type": "markdown",
"id": "22404769-36a7-4aff-bc63-49e1262daed1",
"metadata": {},
"source": [
"Okay, let's run the benchmarks. Due to the noisiness careful parameter selection was required. EVoC will just make use of it's \"pick the best layer\" approach; KMeans again benefits from asking for more clusters than classes to help force it to break up some of the meta-categories to better match with the class labels. UMAP + HDBSCAN required careful tuning of ``min_cluster_size`` to get good results."
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "7d5837d7-97b5-4c25-b596-1fb6b6e5a7d0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:43:02.963427Z",
"iopub.status.busy": "2026-03-25T20:43:02.963202Z",
"iopub.status.idle": "2026-03-25T20:45:35.272333Z",
"shell.execute_reply": "2026-03-25T20:45:35.271486Z",
"shell.execute_reply.started": "2026-03-25T20:43:02.963411Z"
}
},
"outputs": [],
"source": [
"news_results = run_dataset_benchmarks(\n",
" news_data, \n",
" news_target, \n",
" n_runs=32, \n",
" kmeans_kwargs={\"n_clusters\":25}, \n",
" umap_hdbscan_kwargs={\n",
" \"min_samples\":5,\n",
" \"min_cluster_size\":180, \n",
" \"metric\":\"cosine\", \n",
" \"cluster_selection_method\":\"leaf\"\n",
" }\n",
")"
]
},
{
"cell_type": "markdown",
"id": "a6357957-1518-497f-b3e9-d54583be7dcc",
"metadata": {},
"source": [
"Again, let's start with the time taken. Since we have fewer samples everything will be faster, and since we are asking KMeans for fewer clusters we also expect it to be faster as well. What do we see in practice?"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "b64c2dee-159b-48cd-b7c0-cfb5766d4cc4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:45:35.273371Z",
"iopub.status.busy": "2026-03-25T20:45:35.273197Z",
"iopub.status.idle": "2026-03-25T20:45:35.523724Z",
"shell.execute_reply": "2026-03-25T20:45:35.523041Z",
"shell.execute_reply.started": "2026-03-25T20:45:35.273356Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAMVCAYAAADqKmIJAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAY29JREFUeJzt3Qd4FFXbxvE7JHRI6L33jlRBKaJiQVFULAiCvvbeC3axYO+vCPbyKhbsCljoVaogvfdeEmqAsN/1nHwbsskGkZOwSfj/vPbCnZmdnZ3NJnPvOc85UYFAICAAAAAA8JDH58EAAAAAQLAAAAAAkClosQAAAADgjWABAAAAwBvBAgAAAIA3ggUAAAAAbwQLAAAAAN4IFgAAAAC8ESwAAAAAeCNYAAAiLioqSt99951y43GOGjXKPW779u1ZdlwAkB0QLAAAWerKK690F9Zpb2eddVauO/OnnHKK7rjjjpBlJ510ktatW6e4uLiIHRcAHAsxx+RZAAARFQgElJSUpJiYyPzatxDxwQcfhCzLnz+/jgf58uVTuXLlIn0YAJDlaLEAgH/4BvrWW29130IXL15cZcuW1aBBg7Rr1y5dddVVKlq0qGrWrKmhQ4eGPG7u3Lnq0qWLihQp4h5zxRVXaPPmzSnrhw0bpnbt2qlYsWIqWbKkzj33XC1ZsiRl/b59+3TLLbeofPnyKlCggKpVq6b+/fu7dcuXL3ff+M+cOTNle+tmY8us203q7jfDhw9Xy5Yt3UX82LFjXcB4/vnnVaNGDRUsWFBNmzbV119/neU/A/b8dnGd+mbnMyP333+/6tSpo0KFCrljfeSRR7R///6U9Y8//rhOOOEEDRw4UJUrV3bbXXzxxSHdjewctG7dWoULF3bn+eSTT9aKFStS1v/4449q0aKFO7/2HE888YQOHDiQsn7RokXq0KGDW9+gQQP99ttv/9gyM3r0aL322msprTL2XqXtCvXhhx+64/npp59Ut25dd+zdu3d3P1MfffSRe6/t3NjPnYXB1D8T9913nypWrOhe04knnpjyfgNAdkCwAIB/YBd7pUqV0p9//uku9m688UZ3EWtdXKZPn64zzzzTBYfdu3e77a3bS8eOHd2F79SpU12I2LBhgy655JKUfdpF5F133aUpU6bojz/+UJ48eXTBBRfo4MGDbv3rr7+uH374QV9++aUWLFigTz/91F1w/lt2IWqBZN68eWrSpIkefvhh13IwYMAAzZkzR3feead69erlLogzcsMNN7iAdLjbypUrM/XnyAKbXYBbQLML9XfeeUevvPJKyDaLFy9258cCgp1jC1o333yzW2cBoVu3bu59mDVrliZOnKjrrrvOXeAbC1z2um+77Tb3HBZQ7Pmefvppt97ehwsvvFDR0dGaNGmS3n77bRd2DseOs23btrr22mvdz4DdLPSEYz8r9h4PHjzYHbsFBHu+X375xd0++eQTF2BThz4LsuPHj3ePsddkP4PWEmQBCACyhQAAIEMdO3YMtGvXLuX+gQMHAoULFw5cccUVKcvWrVsXsF+nEydOdPcfeeSRwBlnnBGyn1WrVrltFixYEPZ5Nm7c6NbPnj3b3b/11lsDp556auDgwYPptl22bJnbdsaMGSnLtm3b5paNHDnS3bd/7f53332Xss3OnTsDBQoUCEyYMCFkf1dffXWgR48eGZ6DDRs2BBYtWnTY2/79+zN8fJ8+fQLR0dHuvKW+9evXL2UbO9Zvv/02w308//zzgRYtWqTcf+yxx9w+7bwGDR06NJAnTx73fmzZssXtc9SoUWH31759+8AzzzwTsuyTTz4JlC9f3v3/8OHDw+7/n47Tfl5uv/32kGXB98LeI/PBBx+4+4sXL07Z5vrrrw8UKlQosGPHjpRlZ555pltubNuoqKjAmjVrQvZ92mmnBfr27Zvh8QDAsUSNBQD8A/umP8i+wbauS40bN05ZZl2dzMaNG92/06ZN08iRI903+WlZdyfr4mP/Wvce+zbcukgFWyrsm/9GjRq5bjWdO3d2XWXsW2nrKnXGGWf86/fKukEF2Tfze/fudftNzbrYNGvWLMN9lClTxt18dOrUybWSpFaiRIkMt7dv6l999VXXKrFz507XAhEbGxuyTZUqVVSpUqWU+9ZaYOfRWnispcLOobUm2es9/fTTXYuRdS0LvkfWWhRsoTDW7cjOj7UmWAtPuP1nFuv+ZF3oUv8MWYtU6p8ZWxb8mbKWMctf9rOTWmJiovt5BIDsgGABAP8gb968IfetO03qZcHuNcFwYP927dpVzz33XLp9BS9sbb11k7EuPhUqVHCPsUBhF/mmefPmWrZsmavd+P33391FsV0c2wW3dZsyyV/0J0tdf5Ca9cUPCh7fzz//7PrpH2khtXWFsq5Yh2OhxS7EM2LHUatWLR0JC1uXXXaZq3mwYGCjKVn3n5deeumwjwu+D8F/rcuXdXWyrkZffPGF6wZmdRJt2rRx58L2b92P0rKaitTnNu3+j8XPVHBZ6p8pC7UWiOzf1MIFWACIBIIFAGQyCwVDhgxx30CHG4Vpy5Yt7htx69ffvn17t2zcuHHptrNv6C+99FJ3s+Jea7nYunWrSpcu7dZbH/5gS0PqQu6MWAGyBQhrFbFv9I9Uv379dM899xx2GwtHmcXqCKpWraqHHnooZVnqousgex1r165NeW6ro7DQlfpbfTs/duvbt69rcfjss89csLD3yFo2Mgo7dq7C7f9IRoBKXXCdWew12H6tBSP4MwMA2Q3BAgAymRUQW0tEjx49dO+997rCb+vSY9+623Ib8ce6r1hxrrVg2AXsAw88ELIPK1S2dVYAbhfLX331lRtJyUYTsvt2cfzss8+68GJdqezb+CMpiLaAYAXb9g24jUqVkJCgCRMmuG+9+/Tpk2VdoazLzvr160OWWeiyc5OWXezbObHz1apVK9fC8u2334ZtWbBjfvHFF93rsNYJa9mx82StPXZ+zzvvPBcMLEQsXLhQvXv3do999NFHXfcyazWyImg7p1YQPXv2bD311FOudci6odn21lJi+08ddDJi78fkyZPdaFB2Tg/X3evfsLDUs2fPlOOxoGHv+4gRI1y3PBuBDAAijVGhACCT2YWsfetu3zBbVx7r4nT77be7Lj12AWs3u2i2bi22zi70X3jhhZB92EWpdaWyGgm7uLYLVRstKNgN6v3333fdn2y97dsuho/Ek08+6S6qbaSo+vXru+OzUZWqV6+epT8H1h3JglLqmwWbcM4//3x3Tmy4XQtWFnysHiVcALGuTHZRbfUndi7feuutlBqG+fPn66KLLnIX5TYilO3v+uuvd+vtddtwr9Y1ys6vBbWXX37ZtZQYO88WZiwQ2ZC111xzTUg9RkYsuFlXJWvxsJalzBwty7p2WbC4++67Xeix0GQhJqORpwDgWIuyCu5j/qwAAHiweSy+++67I+oCBgA4NmixAAAAAOCNYAEAAADAG12hAAAAAHijxQIAAACAN4IFAAAAgOM7WNiAVja2OANbAQAAAJGVo4PFjh073Ljw9i8AAACAyMnRwQIAAABA9kCwAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAAOT9YrFmzRr169VLJkiVVqFAhnXDCCZo2bVqkDwsAAADAvxCjCNq2bZtOPvlkderUSUOHDlWZMmW0ZMkSFStWLJKHBQAAACAnBYvnnntOlStX1gcffJCyrFq1ahlun5iY6G5BCQkJWX6MAAAAALJ5V6gffvhBLVu21MUXX+xaK5o1a6Z33nknw+379++vuLi4lJuFEgAAAACRFxUIBAKRevICBQq4f++66y4XLv7880/dcccdGjhwoHr37n1ELRYWLuLj4xUbG3tMjx0AAABANgkW+fLlcy0WEyZMSFl22223acqUKZo4ceI/Pt6ChbVcECwAIAdYMFSa9aWUtE+qd47U+BIpOqI9cgEAmSiiv9HLly+vBg0ahCyrX7++hgwZErFjAgBkgd8elca/duj+/J+k+T9Ll34qRUVxygEgF4hojYWNCLVgwYKQZQsXLlTVqlUjdkwAgEy2bYU04Y30yy1cLB3F6QaAXCKiweLOO+/UpEmT9Mwzz2jx4sX67LPPNGjQIN18882RPCwAQGZaMV4KHAy/btkYzjUA5BIRDRatWrXSt99+q88//1yNGjXSk08+qVdffVU9e/aM5GEBADJToVIZryt8mHUAgBwlosXbvijeBoAcIOmA9EYzafvK0OX5iki3zZSKlI7UkQEAckuLBQDgOGAjP/X8WirX5NCy4tWkHoMJFQCQi9BiAQA4djYtTB5utmxDRoMCgFyGAcQBAMdO6TqcbQDIpegKBQA4dg4kSvv3cMYBIBeixQIAkPV2bZaG3i/N/V46eECqeap09vNSqVqcfQDIJWixAABkvf91l/7+Wjq4X1JAWvKH9NG5UuIOzj4A5BIECwBA1lo+Tlo7I/3yHeukv4dw9gEglyBYAACy1rblR7cOAJCjECwAAFmr/AlHtw4AkKMQLAAAWatcI6lBt/TLKzST6p3L2QeAXIJRoQAAWe+id6WKLaTZXyVPkFfvHOnk25Nn5QYA5ArMvA0AAADAG12hAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAACBYAAAAAIo8WCwAAAADeCBYAAAAAvBEsAAAAAHiL8d8FAACS9myXJr4pLRwu5S8qNe0hNeslRUVxegDgOECwAAD4279H+vBcacPsQ8tWjJc2zJHOflZKOiBNeVea/aV0YJ9U7xzppFul/EU4+wCQSxAsAAD+/h4SGiqC/hyUHCB+fzw5VATZtkv+kK4aJkXzpwgAcgNqLAAA/lZPDb88kCTN+zE0VKQ8Zoq04BfOPgDkEgQLAIC/YpUzXrdnW8br1k7n7ANALkGwAAD4O6GnlD8u/fKqJ0vVTs74cXGHCSQAgByFYAEA8Fe0nHTFt1LFlv//1yWv1PBC6dJPpWrtpXJN0j+mcBmp8cWcfQDIJaICgUBAOVRCQoLi4uIUHx+v2NjYSB8OAMDs2iLF5A8d8WnHBumXu6X5vyTXXVTvIJ39vFSmPucMAHIJggUA4NjZt0s6mCQV4MsgAMhtGOMPAHDs5CvM2QaAXIoaCwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAOTsYPH4448rKioq5FauXLlIHhIAAACAoxCjCGvYsKF+//33lPvR0dERPR4AAAAAOTBYxMTE0EoBAAAA5HARr7FYtGiRKlSooOrVq+uyyy7T0qVLM9w2MTFRCQkJITcAAAAAx3mwOPHEE/Xxxx9r+PDheuedd7R+/XqddNJJ2rJlS9jt+/fvr7i4uJRb5cqVj/kxAwAAAEgvKhAIBJRN7Nq1SzVr1tR9992nu+66K2yLhd2CrMXCwkV8fLxiY2OP8dECAAAAyDY1FqkVLlxYjRs3dt2jwsmfP7+7AQAAAMheIl5jkZq1RsybN0/ly5eP9KEAAAAAyCnB4p577tHo0aO1bNkyTZ48Wd27d3fdm/r06RPJwwIAAACQk7pCrV69Wj169NDmzZtVunRptWnTRpMmTVLVqlUjeVgAAAAAcnLx9r9lrRs2OhTF2wAAAEBkZasaCwAAAAA5E8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAW4z/LgAAOAKbFkp/fy0l7ZPqniNVbsVpA4BchGABAMh6U9+XfrpLUiD5/rhXpLa3SGc+zdkHgFyCrlAAgKy1a7M09IFDoSJo4pvSmumcfQDIJQgWAICstfgPKSkx/LoFv3D2ASCXIFgAALJWTP7DrCvA2QeAXIJgAQDIWrXPkAoWT788KlpqdBFnHwByCYIFACBr5SskXfKxVLDEoWV5C0nn/1cqUZ2zDwC5RFQgEEhTTZdzJCQkKC4uTvHx8YqNjY304QAADmf/XmnJH9KBRKnmqVLBYpwvAMhFGG4WAHBs5C0g1TuHsw0AuRRdoQAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8BbjvwsAAI7A399Is7+SkvZJdbtIza6QYvJx6gAglyBYAACy3tD7pclvH7q/+HdpwVCp51dSVBTvAADkAnSFAgBkra1LpckD0y9f/Ju0+A/OPgDkEgQLAEDWWjFRUiCDdeM4+wCQSxAsAABZq0jZo1sHAMhRCBYAgKxVs5NUokb65fljpcaXcPYBIJcgWAAAsvgvTbTU82up8omHlpWul7yscEnOPgDkElGBQCCDjq/ZX0JCguLi4hQfH6/Y2NhIHw4A4J9sWyEl7ZdK1eJcAUAuw3CzAIBjp3hVzjYA5FJ0hQIAAADgjWABAAAAwBvBAgAAAIA3ggUAAAAAbwQLAAAAAN4IFgAAAAC8ESwAAAAAeCNYAAAAAPBGsAAAAADgjZm3AQBZb/8eacyL0uwvpQP7pHrnSJ0elAqX4uwDQC4RFQgEAsqhEhISFBcXp/j4eMXGxkb6cAAAGfnfxdKiX0OXla4nXT9WisnHeQOAXICuUACArLVmevpQYTbNl+Z+z9kHgFyCYAEAyFob5x5m3RzOPgDkEgQLAEDWKln7MOtqcfYBIJcgWAAAslaVE6WqJ6dfXqyq1Ogizj4A5BIECwBA1uvxudTyail/rBRTIDlQXPmzlLcgZx8AcglGhQIAHDvr/pKS9ksVmkl5ojnzAJCLZJsWi/79+ysqKkp33HFHpA8FAJDZNsyR/nuiNLCD9O5p0quNpcV/cJ4BIBfJFsFiypQpGjRokJo0aRLpQwEAZDZrofjfJcnDywYlrJG+6CXt2MD5BoBcIuLBYufOnerZs6feeecdFS9ePNKHAwDIbIt/lxJWp1++f3fyTNwAgFwh4sHi5ptv1jnnnKPTTz/9H7dNTEx0s22nvgEAsrk92zJet3vrsTwSAEAWilEEDR48WNOnT3ddoY60DuOJJ57I8uMCAGSiau2lqGgpkJR+Xc1OnGoAyCUi1mKxatUq3X777fr0009VoECBI3pM3759FR8fn3KzfQAAsrlilaX2d6df3vBCqXqHSBwRACA3DTf73Xff6YILLlB09KHhBpOSktzIUHny5HHdnlKvC8e6QsXFxbmQERsbewyOGgBw1JaMkGZ/LR1IlOqdIzXoJuWJeI9cAEBODxY7duzQihUrQpZdddVVqlevnu6//341atToH/dBsAAAAACO8xqLokWLpgsPhQsXVsmSJY8oVAAAAADIPmiDBgAAAJBzu0JlBrpCAQAAANkDLRYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4C3GfxcAAByB9bOl2V9LSfukeudI1dpx2gAgFyFYAACy3qS3pWH3p7r/ltTqWumcFzn7AJBL0BUKAJC1dm6Ufnsk/fIp70irp3L2ASCXIFgAALLWkhHJ3Z/CWTCUsw8AuQTBAgCQtfIWynhdvsKcfQDIJQgWAICsVbuzVKhkmL9AMVLj7px9AMglCBYAgKyVt6B06f+kImUPLcsfK10wUCpWhbMPALlEVCAQCCiHSkhIUFxcnOLj4xUbGxvpwwEAHE7SfmnZaOnAPql6Byl/Ec4XAOQiDDcLADg2ovNKtU7nbANALkVXKAAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHiL8d8FAACprJku7dkmVW4t5S/6z6dm/17pr8+lpaOkgsWl5ldIFVtwSgEghyFYAAAyx7bl0he9pPWzk+/nKyJ17ie1uvrwoeLj86RVkw8tm/6RdN4bUrNevDMAkIPQFQoAkDm+7HMoVJh9O6Wf75ZWT834MdZSkTpUmMBB6deHk0MHACDHIFgAAPxtmCOtmxlmRUCa8am0b1dyWHixrvRcNen7m6UdG6SlI8Pvz7pSrfuLdwYAchC6QgEA/O1NyHhdYoI0uGdoiLCwsXKyVKVNxo8rVIJ3BgByEFosAAD+KjSTCmYQBErWCt8ysWWRFFdZigrzp6jKSVKp2rwzAJCDECwAAP7yFpC6vCBFRYcur3GKFFsx48cd2COd/9/QUGKhovv7vCsAkMPQFQoAkDkad5fKNkzu5rRnu1Szk9TgfGltuNqL/1eqrnRCD6nhhck1Fdb9iZYKAMiRogKBQEA5VEJCguLi4hQfH6/Y2NhIHw4AICMfdZWWjQldVqKGdOPE5NYOAECOR1coAEDWu+xzqc1NUuHSUv44qenl0pW/ECoAIBehxQIAAACAN1osAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACANybIAwBkvYMHpRmfSLO/kpL2SXW7SCdeL+UtyNkHgFyCYAEAyHo/3po8I3fQqsnSot+kPj9IeaJ5BwAgF6ArFAAga21aGBoqglaMkxYO5+wDQC5BsAAAZK3VUw6z7k/OPgDkEnSFAgBkrdgKh1lXUZr7gzT2JWnTfKl0PanDPVL9rrwrAJDD0GIBAMha1TtKpeunX16whJSviPTlFdK6mdKBvcn/ftFLmvcj7woA5DAECwBA5tibII16Vhp0ivRBF2naR1IgIOXJI/X6Wqp1uqSo5G0rtpR6fyf9OTD8vsa+zLsCAMdLV6jFixdryZIl6tChgwoWLKhAIKCoqP//gwEAOL4c2Cd9fJ60dsahZSvGS+tnSee8JMVVktrcJBWIk/bvlRpfJJVtLG1aEH5/GS0HAOSeYLFlyxZdeumlGjFihAsSixYtUo0aNXTNNdeoWLFieumll7LmSAEA2dfc70NDRdDU96WTbkuew2LMC4eWL/hZavhjck3F2unpH1emXtYeLwAg8l2h7rzzTsXExGjlypUqVKhQynILG8OGDcvs4wMA5OSRnwIHk+erCNe1ac63Ut2zD3WPShEltb8nSw4TAJCNWix+/fVXDR8+XJUqVQpZXrt2ba1YsSIzjw0AkFNYV6eM7NwgBZLCr9u/R7rsM2ncy9LG+cktFe3v/v/AAQDI1cFi165dIS0VQZs3b1b+/Pkz67gAADnJCZcnDxm7d3vo8kqtpIotMn5coRJSvS7JNwDA8dUVyoq1P/7445T7Vmdx8OBBvfDCC+rUqVNmHx8AICcoXCp5lCcLEiYqOnkuiss+l2qdJsWGadHIW0hqfMkxP1QAQNaICthwTv/C3Llzdcopp6hFixaugPu8887TnDlztHXrVo0fP141a9bUsZKQkKC4uDjFx8crNjb2mD0vAOAwdm2WovNJBVL9Xt4wR/r6amnTvOT7FjTOf0OqeSqnEgCO12Bh1q9frwEDBmjatGmutaJ58+a6+eabVb58eR1LBAsAyGHWzZIOJEoVm0t5oiN9NACASAeL7IJgAQA5xJ5t0q+PSH8PkZL2SXXOks58WipeLdJHBgCIVPH2mDFj/rEGAwCAEJ9dKq2afOj+/J+kdX9JN0+W8hXmZAHA8RgsrL4irdQzbiclZTCkIADg+LRiYmioCIpfJf39jdT8ikgcFQAg0qNCbdu2LeS2ceNGNzFeq1at3BwXAACE2Lrk6NYBAHJ3i4WNwpRW586d3RwWNiu3FXQDAJCibKOjWwcAyN0tFhkpXbq0FixYkFm7AwDkFhVOkOqGmQCvTEOp/nmROCIAQHZosZg1a1bIfRtUat26dXr22WfVtGnTzDw2AEBucfGH0rhXpNlfSQf2SfXOkTreJ8Xki/SRAQAiNdxsnjx5XLF22oe1adNG77//vurVq6djheFmAQAAgBzaYrFs2bJ0QcO6QRUoUCAzjwsAAABAbg4WVatWzZojAQAAAJC7g8Xrr79+xDu87bbbfI4HAAAAQG6tsahevfqR7SwqSkuXLtWxQo0FAORw9idoyQhp/SypRI3k0aOi80b6qAAAWdVikbauAgAAb4k7pf9dLK2ccGhZyVpS7x+kuIqcYAA4XuexAAAgrH27pWkfSb/cK016W9qzPXm5DT+bOlSYLYul4Q9yIgHgeCjeNqtXr9YPP/yglStXat++fSHrXn755cw6NgBATrdzo/TB2cmBIWjcy9KVv0hzvwv/mPk/S0kHpOij+hMFAIiQf/1b+48//tB5553n6i5spu1GjRpp+fLlbl6L5s2bZ81RAgByptHPhYYKs3OD9OvDVpmX8eOiDrMOAJA7ukL17dtXd999t/7++283d8WQIUO0atUqdezYURdffHHWHCUAIGdaODz88kW/Sg26hV9Xv6uUJzpLDwsAkA2Cxbx589SnTx/3/zExMdqzZ4+KFCmifv366bnnnsuCQwQA5Fh5C2a8vP1dUrX2octL15PO6n9MDg0AEOGuUIULF1ZiYqL7/woVKmjJkiVq2LChu7958+ZMPjwAQI7W9DLpj37plze5RMpXSLryJ2nZWGntDClxR/KoUFGMKwIAx0WwaNOmjcaPH68GDRronHPOcd2iZs+erW+++catAwAc52xuimCNxEm3SRvmSn9/fWh9jU5S51Rho0CsNGmAtGNt8v08eaXTHpFOvv0YHzgAIMsnyEvNJsDbuXOnmjRpot27d+uee+7RuHHjVKtWLb3yyiuqWrWqjhUmyAOAbGTdX9Jvj0nLRksF4qTmfaROD0kx+aRNC6XJb0vrZkrR+aR650itrk3+/zeaS9vCzJd09W9S5daReCUAgGPRYvHkk0+qV69ebhSoQoUK6a233jqa5wUA5CbbV0kfdpUS45Pv79kmjX81ebjZCwZIE9+Upn90aPuVE5MLuy14hAsVZtaXBAsAyEH+dUfWLVu2uC5QlSpVct2gZs6cmTVHBgDIOaZ9eChUpDbrC2n5+NBQEbR8rLR8XMb7PLA3c48RAJC9goVNjLd+/Xo99thjmjZtmlq0aOHqLZ555hk3n8W/MWDAANelKjY21t3atm2roUOH/ttDAgBEWtq5KoICSdKi3zJ+XGKCVLBE+HXVOkhjXpC+6CX9+oi0NYOWDQBAzqyxCDcL9+eff673339fixYt0oEDB474sT/++KOio6NdfYb56KOP9MILL2jGjBkpI00dDjUWAJBNjH5BGvlU+uVWQ3HhIOmrK8M/rsuLUtFy0tf/kZL2HVpe71xpzfRDBd0mXxGp9/dSpZZZ8AIAAMe8xiK1/fv3a+rUqZo8ebJrrShbtuy/enzXrl1D7j/99NOuFWPSpElhg4UNcxsc6jYYLAAA2UCLK6Up70o714cub3WNVP/85PkpNs0PXVewuNS4e/K/t06XZn8p7Y2Xap0uzf46NFSYfTuTWy7+Q8s2AGRHRzVY+MiRI3Xttde6IGGT5RUtWtS1PtgM3EcrKSlJgwcP1q5du1yXqHD69++vuLi4lFvlypWP+vkAAJmoSGnp6uFS08ulIuWkMg2ks56VznxGypNH6vm1VPNUayhP3r5Cc+mKb5NDhSlWWWp/d/IwtNU7SEtHhX+elROk/dReAECu6AplRdtWwH3mmWeqZ8+ertWhQIECR30ANgeGBYm9e/e6Gbw/++wzdenSJey24VosLFzEx8e7Gg0AQBZJ3JlcgL1kZHIYaN5bqp5m1uwjsWuzlLRfii2ffp2NIGXr4ipKg05JnjQvrfyx0v0rksMKACBnd4V69NFHdfHFF6t48f//lslT3bp13chS27dv15AhQ1wLyOjRo11BeFr58+d3NwDAMbRvt/ThOclzUARZtyWrj2h97b/bV+FS6ZdtWyH9cGvy/BemQjOp5mnhg0WzXoQKAMitxduZ7fTTT1fNmjU1cODAf9yW4m0AOAasduLnu8O3Htw9X8pX+FCLw/jXkrsxuVaNPlKTiw9tv+pPafZX0oHE5OLs2p2lwEHpvydKWxaF7ttGimp6mTT1/eRhZ6Oik+sxur4u5T36VnIAQDYt3s4KlnNSd3cCAERYRnNN2FCxNtt21ZOkPdul984InezO5qmwYWg79ZXGvSL9/vihddat6oReUsML0ocKs2erVLKmdNc8afNCqVgVKbZCFrw4AECuCBYPPvigzj77bFcnsWPHDle8PWrUKA0bNiyShwUASK1w6YzPR+Eyyf9O/zj8DNrWgmHhYUSYoWhnfhq+a1RQwjqpUAmpShveDwDIASIaLDZs2KArrrhC69atc6M82WR5Fio6d+4cycMCAKRmXZqsS9LBNPMUVWsvlUqeh0hrpoU/Zwf2JNdjpH1s0N7tGZ/rkrWkb66XFg6T8hZK7hrV8X66QgFANhXRYPHee+9F8ukBAEeiXCPpovekYX3/f26JqOTuT21uknZvTW5ViKuUwYOjpLgqGe+7eHWp2RXSjE/Sh5aRT0vxqw4FkHEvJ3eLuux/vG8AkA1lu+Ltf4PibQA4hpIOJNdUjHlBWjQ8ufA6poB04g3Jw88OODm5hSI1K9Lu/r70amNp54b0s3LfNlMqWl766/Pkwm4bbrb+ucnTLA27L/xx3DRJKlM/614nAOCoMBA4AODIRMdIs76QFg5NDhXGRmwa/2pygXfPr6RyTf5/2/zJQ8NeMFCKyS/1+Dy5ADuoUEnp4g+T56ywOSma9ZR6fydd9bPU5kZp6+KMjyPtDN4AgGwh240KBQDIpg4mSTMz6IZkozxdO0K6Yay0a4uUr5CUt+Ch9RVbSLf9Ja2aLCUlSlXaJgeOIBuCdsEvUsJaqXIbqVSdjI+jVN1MfFEAgMxCsAAAHJmkfdK+neHXWa1F/Bpp7Iuh81i06HNom/k/SrO+TO7utG25dEJPKTqvtHmx9Em3Q/UUpm6X5LqN+NWhz1PnbKls+glUAQCRR7AAABwZa4Gw1oRVk9Kvq9o2eR6LhFRBwEaK2rpU6vyENOxBadJ/D62zGo0FQ6Ueg6UfbwsNFcZaLzo9JG1aIC0cnvzcTS9NXgYAyJYIFgCAI3fGU8mtC6lbLmIrSYXLhoaKoMlvSw26SZPeSr/OhpH9e4i0Ynz451o2RrryJ94dAMghCBYAgCNXuZV044TkeS1sQrzyJ0gtrpR+uiP89lbcPe8HSRkMQLjqz4yfK+cOWggAxyWCBQDg3yleNbl7U8iyauG3jcpz+ELsEtWTC7lXTky/rmE33hkAyEEYbhYA4K/lf6R8RdIvb3iB1OSS5Inw0spXVGp8idT1NalohdB1dc9JbgkBAOQYTJAHAMgcq6ZIvz6cXNxtocHmpjj98eTC6y1LpG+uk9ZMTd62ZG3pvDeSi77Nfusy9aOUsEaqfOKh5QCAHINgAQDIXPv3JM+qnSc6/TobJcqGmy3NXBQAkNtQYwEAyFypJ8ZLq0QNzjYA5FLUWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAIIFAAAAgMijxQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAcnaw6N+/v1q1aqWiRYuqTJky6tatmxYsWBDJQwIAAACQ04LF6NGjdfPNN2vSpEn67bffdODAAZ1xxhnatWtXJA8LAAAAwL8UFQgEAsomNm3a5FouLHB06NAh3frExER3C0pISFDlypUVHx+v2NjYY3y0AAAAALJljYUFBFOiRIkMu07FxcWl3CxUAAAAAIi8bNNiYYdx/vnna9u2bRo7dmzYbWixAAAAALKnGGUTt9xyi2bNmqVx48ZluE3+/PndDQAAAED2ki2Cxa233qoffvhBY8aMUaVKlSJ9OAAAAAByUrCw7k8WKr799luNGjVK1atXj+ThAAAAAMiJwcKGmv3ss8/0/fffu7ks1q9f75ZbYXbBggUjeWgAAAAAckrxdlRUVNjlH3zwga688sp/fLwNN2shhOFmAQAAgOO8KxQAAACAnC9bzWMBAAAAIGciWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAAAgWAAAAACIPFosAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADwRrAAAAAA4I1gAQAAAMAbwQIAAACAN4IFAAAAAG8ECwAAAADeCBYAAAAAvBEsAAAAAHgjWAAAAADI2cFizJgx6tq1qypUqKCoqCh99913kTwcAAAAADkxWOzatUtNmzbVm2++GcnDAAAAAOApRhF09tlnuxsAAACAnC2iweLfSkxMdLeghISEiB4PAAAAgBxYvN2/f3/FxcWl3CpXrhzpQwIAAACQ04JF3759FR8fn3JbtWpVpA8JAAAAQE7rCpU/f353AwAAAJC95KgWCwAAAADZU0RbLHbu3KnFixen3F+2bJlmzpypEiVKqEqVKpE8NAAAAAD/QlQgEAgoQkaNGqVOnTqlW96nTx99+OGH//h4GxXKirit3iI2NjaLjhIAAABAtg4WvggWAAAAQPZAjQUAAAAAbwQLAAAAAN4IFgAAAAC8ESwAAAAAeCNYAAAAAPBGsAAAAADgjWABAAAAwBvBAgAAAIA3ggUAAAAAbwQLAAAAAN4IFgAAAAC8ESwAAAAAeCNYAAAAAPBGsAAAAADgLcZ/FwAAADheJB1M0vdLvtewZcN0UAfVuUpnXVjnQuXNkzfSh4YII1gAAADgiD00/iH9vPTnlPuT103W+LXj9fqpr3MWj3N0hQIAAMARmbtlbkioCBq5aqSmrp/KWTzOESwAAABwRGZsnHFU63B8IFgAAADgiJQpVCbDdaULleYsHucIFgAAADgip1Q6ReULl0+3vGSBkjqj6hmcxeMcwQIAAABHJG90Xg3qPEjNyjRLWdaoZCMN7DxQhfIWyrKzuHv/bn0+/3M9OPZBvT79da3ZuYZ3LBuKCgQCAeVQCQkJiouLU3x8vGJjYyN9OAAAABGxNH6pVu9YrbrF66ps4bLH5Dk37NqggAIqV7hclj7P9r3b1WdYH/cagwrGFNTbp7+t5mWbZ+lz499huFkAAIAcatf+Xbp/zP0avXq0ux8dFa3udbrrwRMfVJ6orO2YktkBZsn2JXr/7/fdyFOVilTSFQ2uUOvyrfXhnA9DQoXZc2CPnv3zWX3Z9ctMPQb4IVgAAADkUC9OfTElVJikQJK+WPCFaharqR71erhWjM/mf6bF2xarRrEaurze5aoSW0WRZl2brNUhKirK3bfj6zW0lwtK7v72xRqzZoxe6viSmyMjnHlb52nr3q0qUaDEMT12ZIxgAQAAkAMdOHgg7JwS5ttF36pF2Ra6cuiV2rF/h1s2cd1Et/z9s95Xw5INtXnPZn0852P9uf5Pd3F+cZ2L1alKJ+/j2n9wv8asGuP2b8dQq3itlHU/LvlRA/4aoFU7VrkRpv7T6D/qWb+n3pn9TkqoCDoYOKg3ZryhkgVLhn0em+m7QHQB7+NF5iFYAAAA5EDWOmFdgsKxi/Q3Z7yZEiqCdh/YrTemv6H+7fur1y+9Qoqgx64Zq/ta3ee6INl+P537qX5f+btiomJ0dvWzdVm9yxSTJ8YFhy8XfKlhy4a5i//OVTvr8vqXK190Pi2LX6YbfrtBa3etTdnvRbUv0mNtH9OIlSP04LgHU5Zv3L3RdWey7lt/b/477OuwLlB2PFPWT0m37sxqZ2ZpwTj+PYIFAABADpQ/Or9alWsV9qK7faX2+mHJD2EfN3XDVNddKtzIStaacGHtC3XzHzdr2oZpKctnbZ7lJsB76ZSXdN/o+1zgSL3OWkNsZKiHxz0cEirMkEVDXMvFVwu/Cns8VkNRuWhlrdyxMt06a0mx47Fj/WjORy7UmHYV27k6EmQvBAsAAIBsyAKDFTMvj1+u2sVr6+rGV6tp6aYh29zb8l5d8+s1StiXkLKsStEquqbxNRq/Zrx27AttsQjOOTFr06ywz2nbW3ep1KEi6NcVv+rHxT+GhIqgCWsnuG5ZFjLCGb58uOv+FI6Fhr6t+2rSuknp1llNiBWh3978dtdyMX/rfDePRvW46mH3hcgiWAAAAGQzY1aP0W0jbnPdnczqnas1bs04vXvGu26IVZstwC7mrQvRLc1u0ba927Ru1zo1KNlA59U8T4XzFtaldS/Vc1OeS7fvS+pekuE8ENYtaf2u9Rke1+g1hwrF07LRnDJyIHDAHZu9rrTql6ivjpU76ul2T+utmW+5Y4vLH6ee9Xrq2ibXhrRenFThJPf/1gXrqwVfuVYZ697VoVIHV69hj0PkECwAAACymQEzB6SEiiDrBjRw1kC92ulV11UpdRco+xb/vTPeU+XYyu6+BY/TqpzmwoZ1QbKaCSt0trBxVaOrtGjbIn27+FtXAJ7aGdXOcCNKZaRabLUM19UrUc/No7Fg24J0606vcrprdZm0dpL2HdyXstxaI2464Sb3/xaIzq1xruIT41UkXxFXnG2v2V6nBYmWZVu6CfrMU5OeCulaZaNIWY3I5+d87rqIITIIFshR/l4Tr/fHL9OqrbvVsEKcrm5XXZVLULgFAMhdbCjVjFoFbCSntHUVFiCe/vNpN2mctQq8MOUFLU9Y7oqt7aLeRl6ywFA0X1E3sZ21dNiykStHutqGfHny6Zwa5+iB1g+4/Vnh98Y9G0Oeo2ZcTV3X5DoNXTY0XT1E2UJlXRG3DWlrxdvbE7enrDu18qnqVqubO5YPz/pQ7/39nhZsXaCqcVXVvXZ3zdkyRx/8/UHKyFQnVUxulbDXaHN0bNqzyd239c+0e0bV4qq5uo20LCz9svQXXVD7gqM+7/DDzNvIMcYu2qT/fDhF+5MOTRZfrFBefXPjSapRukhEjw0AgMx07rfnakXCinTLG5dqrH1J+8K2CkQpSv/r8j/1HtY7XUuEhYtXOr3iQskr015xXZNM0bxF9XCbh3VK5VPcCEtWq2GjN9n8F5PXTdbMTTNdq4J1NXroxIfcLNvWVanfxH6auHaim3nbCsgfafOIaymwLlnWejJi1QgXCKyVwVogBs0a5CbAs+BxbeNrXcuIPVfPn3u6AJSaFWVb60XnrzqnG9XK5r54sPWDemTCI2HPm7XI2OtBZNBigWxl7/4krd62W6WLFlBcweTmzqBnh84PCRVm++79enPkYr18yQnH+EgBAMg6fRr2cRfv4Za/N/u9sI+xAGDf5KcNFeaPlX+4mowXpr4Qstwu3Pv/2V+nVT3N1XDcPepuV7MQ3N9NTW9S74a9Xc1GUMUiFd0IUBYYrMuV6Tuur3u8KZ6/uO5scadubHqj/ljxh+4cdWfKY634+u7Rd+vlqJddcEobKoKtJdaCkjZUGOvSFe4xqY8NkUOwQLbx3rhlemPEIhcW8sXk0SUtK+nRcxu6/9+zL0lz1h4a8SK1qcu3HfNjBQAgK1mXIAsINiqUFVNXKlJJ1ze93s3dYC0G4bpKWauCzUQdjrUsZDSZnnVbGr1qtJ6Y+ERKqDBW1/DWX2/p1Cqnqm6Juq7blNVl2MhRJ1c8WZfVvUxF8hdx9R7BUGG2JW7TYxMec12WrNtTOO/MekelC5UOu85aMg4XHmLzx7qWEBs2NzUr3D6/1vkZPg5ZL88xeA7gH/08a52e/GmuCxVm34GD+nTSSr34a3JTb/6YPK7bUzhlYynSAgDkPj3q9dCvF/2qCZdNUK8GvTRk4RBd/vPlSjqYpPYV24dsWyOuhutClHY42qAieYsoNl9shs9ltRuph6xNbdjyYS4I3DbyNo1cNdJd0L82/TX9Z/h/3FC4Y1ePDRtkvl74tev+FI5NpGczb4djLSWdKndyXbvCsdduBexWE2IF3qZ5meauC9T2vYdqO3DsESyQLXw8Mfw3E59PXqn9SQeVJ0+UrmhTNew2vdtmPEJFRpIOhnapAgAgO4qKitITk55wM1RbvcPszbP1+ozX3chKn5z9iZv/weaysAvr56c874aLtcnm0rqh6Q1u9uxwrJtTnWJ1MjwG635kNRJpWavJj0t/dCEiHKu3qFW8Vth1tYrV0iV1LnGzeqdlocKG1LVC8bR6N+jtRpey1oln2z+rCT0maMBpA7Rl7xbdO/penf/9+er+Q3ct3rY4w9eDrENXKGQLm3Ymhl2+I/GA9uxP0pad+1SvXFGd16S8fpu30S0rUTifbulUS12bVjii57DuVM8Pn6+vp67Wrn0H1K52aT3YpZ7qlcv4GxwAACLJWhJscrm0rLD6qoZXuVGeHh7/sOu2ZH5b8ZubF+LKhle6Se6KFyiudhXauWFoKxSp4OZ6sO5VQfaN/xMnPeFaAaxVY+f+nWGHmN2btDfs8VmRt024Zxf2aZ1Y/kT32FtH3BoSPqwlwuanqF+yvl7s+KKr+7DuXRYybD4L6wZm3b9sfg6bt8JaTOz1NSvTzNV1fLPoGzeUroWLxKRE3Tvm3pDjtsL2G/+4Ub9c+EtKiwaODUaFQrbwwJBZGjwl/Yyc9cvHqkPtUq7+4sD/tzI0rBCrJ85rqMaV4pQ/JvqIn+OGT6Zp2JzQSX+KF8qrX+/sqNJF6U4FAMh+vpj/hZ6a/FTYddc3uV5fLvjS1TSkZSM4WVch+xZ//NrxbpkN92qTznWt2VWjVo1SgZgCqlS0ktbuXOtCh3Wx6ju2b8g8E/Yc1tLR7ftuYY/B5sSoXax2SLgxdYrX0cdnf+xaQ2z4W2vxWLp9qaoXq+5GhbJRqILscWt2rNHgBYPd67EQY92hzqh6hvqd3M+NBGUF69ZSE3wOW2ahxALJM5OfCXts1l3KAgiOHVoscEzMW5egl35doIlLtqhEkXy6vHVVXd+hhuviZG7uVEu/z9ugzTsP/TLLGx2lTnVL661Rof0zrYjbRoL68KrWYZ/ryymr9NmfK7Vt9z6dVLOUbjm1lqvZSBsqzLbd+/Xl1FXu+QEAyG5seNeMWCtAuFBhJq2b5LpNBUOFsWLwj+Z+5OazsEBwx8g79OLUF1PWV42t6rpXWZcru7jvULGDm5DOWkVsSNm0c2fYyE0X1b7IPc5uNiLVlt1bXGBZu2utrhh6hWsJsVaST7t86h4zbNkwDfhrgAswFj6sIN1aJaZvnK6P536csm8LENZSYa0S3et016vTX03XPeuBsQ+44WUzklEhO7IOwQJZzoaPvXTgRCXsTR7+btfWPXpu2Hxt2pGoR7s2cMtskrsfb22nD8cv11+rt6tKiULqc1I1vTA8/TjdZvTCTdqyM1Eli4S2NLz86wK9PuJQv8oVW1ZqxPwNerBL/QyPb+mmXZn0SgEAyFztKrZz3YnSjpJUpmAZnVn1zLC1D8a6NdlEduF8t/g7d9FtM1WnZsO/2kX/G6e94UaAumXELa5FwFoPrDuVhQQLKnbRb3UcZ1U7y9VZWFeoJqWbuJvVgvxv3v9CJq2zFovPzvlMv6/4XQ+OezBlnYWJm36/yQ1dG27CO/PDkh9UKCb8RLg2OpXNJh6OdbdqXS78F5DIOhRvI8t9MnFFSqhI7dPJK7R996EWivJxBdW3S30Nvq6tnu/e1M2svXtfUth92rDZVmeRWvye/Xpn7LJ0225ISNTs1fGK/v/WkbQaVKDGAgCQPUXnidagzoNcwAiOkmStB++c+Y7qlKgT9uLZtutSo0tIl6bUbH6IcHUbZsyaMZq1aZbuGn2XCxXGgoQttxaSEReP0GNtH9O2Pdv0zux3XFer0746ze3PZvQePH9wun0u3r7YDXUbLgQlBZJcN6fUM3WnbZkINy9HkLW+2Izfadms4taKgmOLFgtkuUUb0xeCGeuetHLrbs1dl+C6L23fs1/ta5dWj9aVVShf8o/mqfXK6M9l6Zsy65YtqkrFC2n55l06cPCgapUpqiWbdqYLG0Ertu7WJS0r6/M/V4Ysr1S8oLq3qJQprxMAgKxQvkh5DTh9gBsO9uDBgypWoFjKuuc6POcu7oNzOljXobta3OW6FzUp1USzNs9Ktz8LKTZrdkasRSPcxbzNVbFx90Y3+pRd8AfZ3BfWtenxkx53QSEcCysZzU1hQ9LaBH02BG24mcbPqXmOPpn3SdhWGXudNorUL8t+cZMA2qhYXap3cfvDsUewQIbWbt/jWgFqlymimOijb9yyx4+YvzHdcpv4bsLizXp22KHuTqMWbNL3M9foy+vbqkDeaPVuW1W/zlmv6SsPfZNROF+0ru9YQ+e9OU6zVse7ZbXKFFHfs+u5VolwQ8la1yrrDlWzdGF9NXW1duzdr451y+j202qnm+EbAIDsKNw8FKUKltIHZ33g5pOwb/1tpKX80cndhO9rfZ+u/+167dp/qMtv9bjqbjQpuyi3WbDTOrnCyRm2HphfV/waEiqC9h/cn+GcFcaKxK0FwbpbpWUF3dc0vsZN0me1GUFWoH1Py3vUsGRD3XTCTRowc0DK6FL2Gp9q95QK5U3uJmUF6XZDZDEqFNKx2oW7v/rL1TFYlyObgM5mwD6nSfmjrrHo8trYdN2hbF6Kb2es0c7E9N+KPHNBY11+YhWt2rpb30xfrdlr4hUTHaXGFYup2wkVdMnASVqzPfQXW2yBGHWoU1o/zVoXsrxg3mgNvb29qpUqzLsNADiu2LCtNjyrdWuyb//Pq3meuxjfe2Cvq6GwYWuDKhapqHfPeNeNGPXclOfS7cvCiF3gW4tFOHe2uFMT1k4I2acpmreovu/2vVtno0elZvUbb5/+ttpWaOsmt/t60dduiN0KhSvokrqXqEpslZRtLTyNXj3ahYozqp2hEgVKZMIZQmYiWCCdnu9O0vjFoeNRW0vAj7e0O+p6hPnrbVSoha6FwgquLTQ0rRinHu+G/vIJsrkpTq9fRnd/+VfKMLOmV5sq6lC7tK77ZFrYx1kx+Oqte/TV1FVuDozmVYq5loqW1fjlAwBAuNGj/t78tyoVqeSGZrVRoKyFo9cvvVxtRGr3tbrP1TOcNeSsdF2erK7DwoNd7FsBt9VcWCuGzQT+QOsH1KhUI7fdT0t/0gd/f+BaLuqWqKsbmtyg9pVCZxFHzkWwQIhlm3ep04ujwp4V65bU7/xGSjyQpL/XxCu2QF7VLlv0qM/g4o07dPrLY8Ku63ViFX03c23Y1oxr2lXXu+PS98M01rXpzs51XHcoq+EomO/I57lIzR4/edkWt482NUq6blkAABwvrJ7DCrGtFqNY/mK6qM5FrjbDfDTno5Bhao21ZNzY9MaU+9YiYsXj4bpvIfeixgLpukFlZPPORFf/0O/HudqyK3mkiWZViumNHs1cIXUwLPwwc632JQXUuUFZtahaPOXxu/cdcN2U1mzboxOqFFPH2qV1YvUSmpymODsmT5Qrxg4XKozNT5GRE2uUSGlhOVyosODw29z1Gr1ws+tCdWHzSqpbLjkkzVi5TTf/b7rWxifPMmo1GP0vbKwujY+uKxgAADmNBYLrmlznbmn1adjH1WIMXzHcjRhlrRj1StQL2cbmsrD/cHyhxQIh7OL/xGf+0I4ww8Pe3Kmm3h69NF1xdOOKcW4Oik8nrdAj3//t6jJSty48fG4DLd6403WxsqFfg9rUKKEXujfVg9/O1thFm92ycrEF9FjXBiqUP0Z93v8z7LtzQ8ea7jg/nhhaAHZmw7IaeEVLV9Px5dTV2hC/Vy2rFdd5J1QImaHbjv/6T6a5CfmCbCRaO5Zzm5bXyc+OdCEqNZusb9S9nVSxWEF+YgAAAMKgxQIhbJjX+86sq0e+nxOyvFHFWCXsORB2xCUrrB6/eJP6/TQ3JFQY67JkF/b9f5kfEirMpKVb9cNfa/XJ1Sfqz2VbNHjKKu3ce8ANG3txy8oqUzS/Nu4IfUxUlHT+CRVUr1xRlSqSX6MXbFSRAnldYfmFzSq6Go7/fDRFe/cfdNt/MXWVCzyfXdtGhfMn/7gPn7M+JFQYe1lP/DjHjVSVNlSY/UkB11pz0ynM0A0AABAOwQLpXNG2muuK9MWUldq22+aWKKUerau41oiMWIuD1SOE8/NfazVxaWgxeJBd5Ft3qD7vT0mZg+LXuRv02eSVerJbIz307Wxt3pnc9cku+h8+p77r5nTaS6O1dHPy8HlF88e4Ym8bEteOMRgqgv5aHa9PJq1wLR1mZJihb42NWjVnbfLwteHsyqBrFgAAAAgWxy3rLmRzS+SPyaOzGpZXXKHQuRza1izpbqm1q1VK30xPnoUztSL5Y1TnMEXc+fNFu65GYRo7XEh46ud56Sa2s/qGcYs2a/wDp2r0gk1uvT1/8UL5dNrLo12ReZCN/nTf13+pVOF8WrLp0PLULEwEg0WRAhnnaQs5741b5loo0upUt0yGjwMAADjeHf2sZ8ixBo5eog7Pj9Sj38/R/UNmq03/P/RHmq5B4ZzbpILa1iiZrmvSA2fX09mNyqtYmnASDA4Xt6ic4UX5WQ3Laeaq8BPxjF+82dVGnNGwnM4/oaIbpnbqim0hoSLIQsuIBRvd8YRTNFWYuKh5pbDb2UR+neqV1X1nhhagmctaVWbIWgAAgMOgK9RxZt66BPUfGjrTprUG3PHFTE1+8DTli87jah2G/r3OjUlttQuXtKzsAoJ1RfroP6317tilGrd4syoVL6jLWldR8yrJIz8N6NlCN382XVv/f8Qom5ju6QsaqXKJQq5b04r3/3RF3EHnNC7vhrB9/Y9F2rUvtMXCWFDZuz9JoxZs1O59SWpfu7R2Ju7P8LXZfBcWYMLN8t29RaWU/29UMc5NwPfUT3NTntdm5H77ihbu/zvWLa3yxQpoxsrtbmjdMxqUcxPvGav/2LwjUQ0rxrmWmiBrXXlz5CLNX79DNUoVdq0jFogAAACOFwSL48wvs0NnpQ6yUaDGLNzkujpZjUOQBYiJS7bo9R7NtGlHom4fPEMTliTXS9gwrU0rF0sJFtZ16s3Lm2nY3+tVpmgB9T6pqpvrwlQoVlDD7+igMYs2ae32PWpaqZi7wDdWqP3hhOXpjsnmjzjp2REpQcVGZrq7cx0VzhcdNohYqDihcjFd/8lUTV+Z3ApiQemGjjV0VqPkoWKtdeSlXxdoyvKtrvj73KaldHGLimpRtYQrQj/95dEp4adO2SJ6+ZIT3HHaMLy3fn7otdsx3HVGXV3drrprWenzwZ8phe323DaB338vb37Us5UDAADkNASL40zaUZtSW7hhZ0ioCLKRm65tX0PPD5+fcmEdLHZ++Lu/XX1F/fKxuvrDKSFzUkxculnv9G7pRpqyMPHfkYvd40sUzqcCMdEpwcK6Um3fvc89j12bF8ibR1efXF1fTludEiqM1T08N3yBbu1UW2+MXBTyWqxL1Wn1yihPnih9c9PJbgK/jTv2qkmlYi5AGAsMl78zybV+mNXb9uiLKatUqkg+1S8fpys/mBLyfHY+rvzgT42971Td+/WskNduwebJn+a68DFg1JKwo2W9MWIRwQIAABw3CBbHmbMaldObIxenW27deg4eJnX8Nm99ylwTqdlDBv+5ytUwpJ3obvziLXrlt4W6vmNNXfjWBK1PSJ5wzmokpq3YplXbduuO0+u4Wa1fvayZHji7vtZs3+1GpLJJ6v47aknY59uXdFA/39pe30xfrV37DriWitPrl3WhwizcsENvjlisScu2qGThfOrVpqquPKmaPhi/LCVUpPbh+OWuRSV1qAiyEam+nLpKIxeEH0nKuo0tWL8j7LoFG8IvBwAAyI0IFscZayW4q3MdvfL7wpRv/K124sWLm2hXYvqL7iBrdciItTZkdOH9/cy1bv6IYKhIbdCYpfpPu+quu5R1TZq9Ol5VShRyISfcqExBNqxt9VKFVbtsEf29JkErt+5W/J79Kl44n1Zt3a3uAya41pTkY9uvJ36c67pxpa7vSM1aH1aEKQgPstaWjDJX/O79qlm6iLbsCg1VxmotAAAAjhcEi+PQbafVdvM+/D53g+t2dHbj8q670J59SXp22Hx3EZ6azTZ9RZuqenfssrCTx7WrXUp/pmmtCNqfdNB1SwrHWg/mrU1wLRNW3xFUq0wRDbqihQsYO8PMHWEzdnd9c1xIULDuSJ9f10ZfTV2VEipSsxoOe81pW1WMPU/nBuU0aOyyDEfD+mnWOq3ZvifdOpvjwwLOlI+2pgsfTKYHAACOJww3e5yyb/yv7VDDTYYXrEEomC9an159oppVKZayXcuqxfXx1a1dq8OjXRu40aFSa1IpTpe2qqzODcuGfZ4zG5Zzo0KFY/uy+SVShwpjgeGF4Qv0zIWNFZPm+WwUKZtsL23rw5Zd+1zNw4INOzMMMafVLxMyklOQtZq0ql7CDUMbbpjZxpXi3Gu34vHUGlaIVc82VXVqvbJ6u1cLd9+O14atffmSproo1UhUAAAAuV1UIHC4ct7sLSEhQXFxcYqPj1dsbGykDydXWRe/R3miolQ2tkDIcpuZ2moqtuxKVNuapdS9eSUXSDYm7NVlgyalzIYdHMJ18HVttWPvfnV5fWy6GbEvbF5Rc9YkhK1FsAv0Of3OdN2QrCbEumld3rqyOtQpo1NeGKnlW3ane4zNTWEtKx9PXJFunU0E+OdDp7uuUq/+vtC1XJQpml+921ZTn5OquW3so2AtEz/PWuf2ZS0VXRqXU1TUodqNz/9c6Vp0TqxRMuW1AwAAgGCBTGRzPgydvV6LNu5wI0XZpHlWv2EmL92iZ4bO11+rtrtWA5tXwkaDOu/NcW70pXCtGZ9dc6Ib4nXj/3fNsnBgLQefTlrp5uNIy7p1/Xxbe533xrh0w9HasLCPnNuA9xsAACCL0GKBY2pX4gEXEGKikwOHjRr12h+L0m13ar0ymrs2IV3RtzUeXNu+ugaNWRa229KzFzXRrNXbXVeqyUu3qmSR5FGhbMK6tN24AAAAkHko3sYxZbUaqV3fsYarmUhd/G0jQ53bpHzYGbSt45510bIWjyHTV6cUTLerVUoPnlPf/b/NXfHJ1Sdm9UsBAABAKgQLRJQNY/vFdW3cDN+zVseraslCOqNBOY3KYPhak3jgoF68uKluPbWWa9WoUrKQGlZInmwPAAAAkUGwQMRZcXT72qXdLahtzZIqnC86Xa2E6Vw/eQSqqiULuxsAAAAij+FmkS0VLZBX/c5vlK4uokfryjqpVqmIHRcAAADCo3gb2dqyzbv03Yw12rM/yRV0t6lRMtKHBAAAgDAIFgAAAAC80RUKAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvBAsAAAAA3ggWAAAAALwRLAAAAAB4I1gAAAAA8EawAAAAAOCNYAEAAADAG8ECAAAAgDeCBQAAAABvMcrBAoGA+zchISHShwIAAADkWkWLFlVUVFTuDRY7duxw/1auXDnShwIAAADkWvHx8YqNjT3sNlGB4Nf+OdDBgwe1du3aI0pQyLmsRcrC46pVq/7xBxpA9sbnGcg9+DwfX4rm9haLPHnyqFKlSpE+DBwjFioIFkDuwOcZyD34PCOI4m0AAAAA3ggWAAAAALwRLJDt5c+fX4899pj7F0DOxucZyD34PCNXFW8DAAAAyB5osQAAAADgjWABAAAAwBvBAgAAAIA3ggW8nHLKKbrjjjs4iwAAAMc5ggUAAMBx6sorr3SzKae9nXrqqSpVqpSeeuqpsI/r37+/W79v374jep6RI0eqS5cuKlmypAoVKqQGDRro7rvv1po1azL5FSGSCBYAAADHsbPOOkvr1q0LuQ0ZMkS9evXShx9+qHADiH7wwQe64oorlC9fvn/c/8CBA3X66aerXLlybr9z587V22+/rfj4eL300ktZ9KoQCQQLZKphw4YpLi5OH3/8sfsWpFu3bnrmmWdUtmxZFStWTE888YQOHDige++9VyVKlFClSpX0/vvvh+zDvr249NJLVbx4cffNxvnnn6/ly5enrJ8yZYo6d+7svimx5+rYsaOmT58esg/7tuXdd9/VBRdc4L4ZqV27tn744YeU9du2bVPPnj1VunRpFSxY0K23X5IAwqtWrZpeffXVkGUnnHCCHn/88ZTPnF08nHvuue4zV79+fU2cOFGLFy92XSYLFy6stm3basmSJSmPt/+3z7f9fihSpIhatWql33//Pd3zPvnkk7r88svdNhUqVNAbb7zB2wRk8nwUdtGf+mZ/g6+++mr3OR0zZkzI9mPHjtWiRYvc+oMHD6pfv37u77ntx34v2LVA0OrVq3Xbbbe5m/29t98H9rnu0KGD+zv96KOP8l7mIgQLZJrBgwfrkksucaGid+/ebtmIESO0du1a90vp5ZdfdhchduFhv7AmT56sG264wd1WrVrltt+9e7c6derkLiDsMePGjXP/b9+mBJtbd+zYoT59+rhfbJMmTXKhwJpXbXlqFmLseGbNmuXWW5DYunWrW/fII4+4b0yGDh2qefPmacCAAS6oADh6FgDssz9z5kzVq1fPhYHrr79effv21dSpU902t9xyS8r2O3fudJ9NCxMzZszQmWeeqa5du2rlypUh+33hhRfUpEkT9wWC7evOO+/Ub7/9xlsFZLHGjRu7wJ/2izcLCK1bt1ajRo302muvuVaHF1980f29tc/xeeed54KH+eqrr9zf7/vuuy/sc9iXjshFbII84Gh17NgxcPvttwf++9//BuLi4gIjRoxIWdenT59A1apVA0lJSSnL6tatG2jfvn3K/QMHDgQKFy4c+Pzzz9399957z21z8ODBlG0SExMDBQsWDAwfPjzsMdg+ihYtGvjxxx9TltmP9sMPP5xyf+fOnYGoqKjA0KFD3f2uXbsGrrrqKt544AjZZ/mVV14JWda0adPAY489FvYzN3HiRLfMPtNB9jkvUKDAYZ+nQYMGgTfeeCPkec8666yQbS699NLA2WefzXsHZAL7Wx0dHe3+Fqe+9evXz60fMGCAu79jxw533/61+wMHDnT3K1SoEHj66adD9tmqVavATTfd5P7/xhtvDMTGxvJeHSdosYA36y9pI0P9+uuvrrUhtYYNGypPnkM/Ztblwb4BCYqOjnbdnTZu3OjuT5s2zXWdKFq0qGupsJt1mdq7d29KFwrb1lo56tSp47pC2c2++Uz7Lad9wxlk3TBsn8HnufHGG10LizXZ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",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" news_results[news_results.measure == \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" kind=\"swarm\", \n",
" col=\"measure\",\n",
" height=8,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "5b049931-a1ea-4e16-8832-ab39fda93e5d",
"metadata": {},
"source": [
"Both KMeans and EVoC produce sub-second timings. This time KMeans has a slight edge in speed, but the distributions overlap, so it is hard to say with confidence that EVoC is significantly slower than KMeans even in this case. UMAP + HDBSCAN was generally much faster than before, but still lags significantly behind the competition in run time. How about quality?"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "55978199-7589-48de-b8b8-ce479f09286a",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:45:35.524356Z",
"iopub.status.busy": "2026-03-25T20:45:35.524201Z",
"iopub.status.idle": "2026-03-25T20:45:36.389650Z",
"shell.execute_reply": "2026-03-25T20:45:36.389123Z",
"shell.execute_reply.started": "2026-03-25T20:45:35.524339Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 15,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAPeCAYAAAARWnkoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3QV8FFfXx/F/lODu7u5Q3CktLYVSo0bdlfpDvdSdGlSpK6WlBhRaKO7u7gSH4ARCns+52w272d0gzRJCft/3s2/ZmdnZ2dl9cufMPffciOTk5GQBAAAAAIB0F5n+uwQAAAAAAATdAAAAAACEET3dAAAAAACECUE3AAAAAABhQtANAAAAAECYEHQDAAAAABAmBN0AAAAAAIQJQTcAAAAAAGFC0A0AAAAAQJgQdAM4YU8//bTq1asX8vnppG3bturVq5dOV9ddd50uvPDCM+Z9AOBMQDuXccqVK6e+ffum6z737duniy++WHny5FFERIR27typzOCff/7JVMeL0Ai6AWjChAmKiorSueeee1Jn48EHH9Tff/+dKQPlzz77zDVo3kfRokV1wQUXaP78+Tod0OACwH9HOxeh6tWrB5yXH374wbV9FuhmlhvaJ3Oj//PPP9fYsWPd7yA+Pl558+bV6SbYOW3evPlpe7w4MQTdQAZLTk7W4cOHM/QYBgwYoLvvvlvjxo3TmjVrTvj1uXLlUsGCBZVZ2Z1va9Q2bNigP/74Q3v37tX555+vxMTEjD40AMj0aOcyXs6cObV582ZNnDgxoP0vU6aMznTLly93Nx1q1aqlYsWKuRsNJyopKUlHjhzRqRQbG3vSx4vTC0E3Mi27I2iBot0VzJ8/v+uh/PDDD13AdP311yt37tyqWLGihg4d6ve6BQsW6LzzznOBor2mZ8+e2rp1a8r6YcOGqWXLlsqXL58LJLt06eL+WHtZIHbXXXepePHiiouLc3eHX3zxRbdu1apV7g/jrFmzUra3lCBbZj2Wvj2Xf/75pxo1aqRs2bK5u692UfLKK6+oQoUKyp49u+rWrasff/wx7OfRzpfd6b799tvdZ7We39Reeukld67snN544406cOBAmnedg92ttdRmS3H26tevnypXruzOoe37kksuccttm9GjR+utt95K6X2283o83519lmuuucatt+/n9ddfP65zYO9hjZq9xr6T++67T6tX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AcunTp2qW265RWeS0/kCCwCQfjZu3Ki7777btcPZsmVT6dKldcEFF+jvv/8Oy2n+559/XBuzc+fOdNmfXTuE61h92fXJI4884s5TXFycChcu7G54//7772F/b+BkRZ/0KwGkm+TkZCUlJSk6OmP+J3nuuefq008/1aFDhzR27FjddNNNrlHr379/wLa2jQXnGcXePxRreBH6vGXk9wYAp7uMbItXrVqlFi1auBvKr7zyiurUqeP+bv/555+68847tWjRIp3u5y1XrlzuEW633XabpkyZonfffVc1atTQtm3bNGHCBPffcElMTFRsbGzY9o8zHz3dyNTszqbdFbZ03vz586to0aL68MMPXcB4/fXXK3fu3KpYsaKGDh3q97oFCxbovPPOc42DvaZnz57aunVryvphw4apZcuWrvErWLCgunTpouXLl/v98b3rrrtUvHhxd5fV0ppffPHFlIbT7hzPmjUrZXu7i2zL7K6y791la0wbNWrk7mhbsGsNlzW2dvc2e/bsqlu3rn788cewn0d7/2LFirm76ldeeaWuuuqqlN5Vb7rYgAEDUu6+23GuWbNG3bp1c+cwT548uuyyy7Rp06aUfXpf98EHH7j95siRQ5deeqnfHfUjR46oT58+KlWqlNuvbW/n3st7Ln/44Qf3Xdu5/uqrr9x3m5CQkNJDb+8VLL38eI/xyy+/dK/NmzevLr/8cu3evTvN8zV+/Hi1adPGfSb73Z1zzjnasWPHcfdU2+/KeuuP9Vuyf5vu3bu7/Xifm99++00NGzZ0r7Hv5ZlnntHhw4f93vf99993nz9nzpx67rnnjut1S5cuVevWrd16u5gZMWJEmucCAGiL/7s77rjD/d22YPKSSy5RlSpVVLNmTd1///2aNGnScfdU27WHLbP206xevdr1lltbZW2B7XPIkCFufbt27dw2ts5eY5lv5ljXIqGuYVKnl9v+LrzwQr322muujbPrKbuB4HvzPD4+Xueff757n/Lly+ubb7455lAxa8ceffRRdx1n21qbZteC1157bco2Bw8e1MMPP+yuP+z4KleurE8++SRl/ejRo3XWWWe5dXZs//vf//zaQvtNW9ts579QoUI6++yzj+v6EQiFoBuZ3ueff+7+IFpDZX90b7/9dhfcNW/eXDNmzHABkf1R3LdvX8ofeAuYrGGYNm2aC/IsELOAzMuC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VEljx47l0wUAZDnOqQDSInQDsL744gsbdk3Qff311zVixAgbrs0fDldddZX8/Py0ePFiffTRR3r88cfdvmuPPvqoHn74Ya1atcqG7yuuuEJRUVF22zPPPKP169dr2rRp2rBhg8aMGaOSJUvabbGxserUqZP279+vyZMn6++//7YXAMzPdm7v1auXDdpm3z169FCfPn20e/dul5//1ltv2bBuHnPPPffo7rvvthcCAADISpxTAbhIBpDvderUKbl9+/Yu70OLFi2SH3/88eTff/892c/PL3nPnj0p26ZNm5Zsfn389NNP9v6OHTvs/VdffTXlMQkJCckVKlRIfu211+z9Pn36JA8ePNjte/3xxx8nFylSJDkqKuqcP4u6desmv//++yn3w8PDk2+66aaU+0lJScmlS5dOHjNmTL7/fAEAWYdzKoD06OkGYDVs2NDlnShbtqwiIiJsr7QZql2hQoWUbW3atHH7rqVt9/f3t73O5vmG6XUeP368GjdubHuxFy5cmPJYU/W8SZMmdmi5OydOnLDPMUPbixYtaoeYmx7s9D3daV+DqaZuhsmb1wAAQFbinAogLUI3ACsgIMDlnTCh1QzvTk42ndjKsO1cOR/bs2dP7dq1yy5NZoaRd+vWTY888ojdZuZ4n40Ztj5x4kS9/PLLmjdvng3pDRo0UHx8/Dm9BgAAshLnVABpEboBnJXpXTY9yiYoOy1atMjtY82cb6czZ85oxYoVql27dkqbKaJ2yy236Ouvv9Y777yTUujM9AiYIH3kyBG3+zVB2zzvyiuvtGHb9GCbtcEBAMhNOKcC+ROhG8BZXXLJJapVq5YGDRpkC5yZAPzUU0+5feyHH36on376yQ79vvfee23FclNN3Hj22Wf1yy+/aOvWrVq3bp2mTJmiOnXq2G033HCDDdL9+vXTggULtH37dtuz7Qz31atX16RJk2wwN8cwYMAAerABALkO51QgfyJ0Azj7LwlfXxuk4+Li1LJlS91+++12mLc7r776ql577TU1atTIhnMTsp0VygMDAzV8+HDbq22WHzPV0M0cb+e2GTNmqHTp0rZKuenNNvsyjzHefvttFStWzFZEN1XLTfXypk2b8skBAHIVzqlA/uRjqqll90EAAAAAAJAX0dMNAAAAAICXELoBAAAAAPASQjcAAAAAAF5C6AYAAAAAwEsI3QAAAAAAeAmhGwAAAAAALyF0AwAAAADgJYRuAAAAAAC8hNANwMXOnTvl4+Oj1atX55if1blzZw0dOtTrxwMAQGbjvAqA0A0g21SsWFEHDhxQ/fr17f3Zs2fbEH7s2DE+FQAAOK8CeYJ/dh8AgPwpPj5egYGBKlOmTHYfCgAAuR7nVSDnoqcbyIemT5+u9u3bq2jRoipRooR69+6tbdu2eXz85MmTVaNGDQUHB6tLly764osvMvRIT5w4UfXq1VOBAgVUuXJlvfXWWy77MG0vvfSSbrnlFoWGhuqOO+5wGXJn/m32bRQrVsy2m8c6JSUl6bHHHlPx4sVtUH/++edd9m8e//HHH9vXUrBgQdWpU0eLFi3S1q1b7fD0QoUKqU2bNmd9nQAAXAjOqwDOKhlAvvPjjz8mT5w4MXnz5s3Jq1atSu7Tp09ygwYNkhMTE5N37NiRbH41mHbD3A8ICEh+5JFHkjdu3Jj83XffJZcvX94+5ujRo/Yxy5cvT/b19U0eMWJE8qZNm5I///zz5ODgYPtfp/Dw8OSQkJDkN954I3nLli32lvZnnTlzxh6TuW/2ceDAgeRjx47Z53bq1Mk+9/nnn7fH/MUXXyT7+Pgkz5gxI2X/5nnmuCZMmGCf369fv+TKlSsnd+3aNXn69OnJ69evT27dunXyZZddluXvNwAgb+O8CuBsCN0AkiMiImxoXbt2bYbQ/fjjjyfXr1/f5V166qmnXEL3gAEDki+99FKXxzz66KPJdevWdQndJginlf5n/fXXXy77dTKhu3379i5tLVq0sMeW8stMSn766adT7i9atMi2ffrppylt5oJBUFAQnzgAwKs4rwJIi+HlQD5khlgPGDBAVatWVUhIiKpUqWLbd+/eneGxmzZtUosWLVzaWrZs6XJ/w4YNateunUubub9lyxYlJiamtDVv3vyCj7lhw4Yu98uWLauIiAiPjwkLC7P/bdCggUvb6dOnFRMTc8HHAQBAepxXOa8CZ0MhNSAf6tOnj60cPm7cOJUrV87OlzYVxE0RlvRMJ7KZL52+7XwfY5h51RcqICDA5b75eea4PT3GeTzu2tI/DwCAi8F5lfMqcDaEbiCfiYqKsj3TpuhYhw4dbNv8+fM9Pr527dqaOnWqS9vy5ctd7tetWzfDPhYuXKiaNWvKz8/vnI/NVDM30vaOAwCQk3FeBfBfGF4O5DOmMripWD527Fhb2XvWrFl66KGHPD7+rrvu0saNG/X4449r8+bN+v777/W///3Ppef44Ycf1p9//qkXX3zRPsZUN//ggw/0yCOPnNexhYeH231OmTJFhw8fVmxs7EW+WgAAvIvzKoD/QugG8hlfX1+NHz9eK1assEPKhw0bpjfeeMPj48187x9//FGTJk2yc6bHjBmjp556ym4zy4MZTZs2tWHc7Nfs89lnn9WIESNclvw6F+XLl9cLL7ygJ554ws6/vu+++y7y1QIA4F2cVwH8Fx9TTe0/HwUAabz88sv66KOPtGfPHt4XAAAuEudVIG9jTjeA/zR69GhbwdwMS1+wYIHtGacXGgCAC8N5FchfCN0A/pNZ+uull17SkSNHVKlSJTuHe/jw4bxzAABcAM6rQP7C8HIAAAAAALyEQmoAAAAAAHgJoRsAAAAAAC8hdAMAAAAA4CWEbgAAAAAAvITQDQAAAACAlxC6AQAAAADwEkI3AAAAAABeQugGAAAAAMBLCN0AAAAAAMg7/g8/+xZrN6+dXwAAAABJRU5ErkJggg==",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" news_results[news_results.measure != \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" col=\"measure\", \n",
" kind=\"swarm\", \n",
" col_wrap=2,\n",
" height=5,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "2bb61a83-1f7d-48e2-9c46-6d1b98699e42",
"metadata": {},
"source": [
"Both ARI and AMI scores for all the algorithms are much lower here -- this is a significantly noiser and harder to cluster dataset. Nonetheless KMeans does consistently worse in terms of cluster quality over the points actually clustered by the algorithms. This came at a cost for UMAP + HDBSCAN and EVoC however: they consistently cluster between about 50% and 75% of the dataset and leave a lot of data unclustered as noise. That is to be expected to some degree, but does represent a challenge if you want cluster labels for most of your data. This shows up in the clustering score, with all three approaches being much closer with overlap among the distributions. Still, EVoC has a notable edge in quality overall, and for an approach that ran as fast as KMeans that's quite powerful. "
]
},
{
"cell_type": "markdown",
"id": "41647cad-0221-42b0-a06a-7d96529de4b0",
"metadata": {},
"source": [
"## Audio embeddings\n",
"\n",
"Audio embedding models are increasingly common. As a test dataset we'll use the BirdCLEF 2023 dataset. This was a kaggle competition for correctly identifying bird species based on audio clips of their songs. Agsin this is quite challenging. The audio is (literally) noisy, with potential for a lot of background noise and even other bird calls intruding into the background of audio clips. We also need a good embedding model representation of this audio id we have any hope that clustering can match against the class labels. Fortunately someone embedded the dataset using Google's Bird Vocalization Classifier and put that data on Huggingface, so we can simply download the embeddings. The dataset has a large number of possible species, and some of those have fewer than 100 recordings, making clustering them quite difficult. To make things a little easier on our clustering algorithms we'll prune out all samples from species that have 100 or fewer recordings in the dataset."
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "c56939cb-662a-445e-9f11-2266c46b7aa5",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:45:36.390262Z",
"iopub.status.busy": "2026-03-25T20:45:36.390117Z",
"iopub.status.idle": "2026-03-25T20:45:42.425450Z",
"shell.execute_reply": "2026-03-25T20:45:42.424318Z",
"shell.execute_reply.started": "2026-03-25T20:45:36.390248Z"
}
},
"outputs": [],
"source": [
"ds_birdclef = load_dataset(\"Syoy/birdclef_2023_train\")\n",
"birdclef2023_data = np.asarray(ds_birdclef[\"train\"][\"embeddings\"])\n",
"birdclef2023_target = np.asarray(ds_birdclef[\"train\"][\"primary_label\"])\n",
"# Only use bird species with at least 100 samples -- this is still very challenging\n",
"mask = np.isin(\n",
" birdclef2023_target,\n",
" np.where(np.bincount(birdclef2023_target) > 100)[0],\n",
")\n",
"birdclef2023_data = birdclef2023_data[mask]\n",
"birdclef2023_target = birdclef2023_target[mask]"
]
},
{
"cell_type": "markdown",
"id": "33a72cf7-6a1e-40b1-bcc5-092b2762d50b",
"metadata": {},
"source": [
"Now we just need to run all the algorithms. Again runing was required to find the right number of clusters for KMeans to get good results. UMAP + HDBSCAN was a little wasier to tune, as we have a concrete idea of the ``min_cluster_size`` based on our pruning."
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "2ca28659-046b-407a-be97-ab334d019473",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:45:42.426718Z",
"iopub.status.busy": "2026-03-25T20:45:42.426496Z",
"iopub.status.idle": "2026-03-25T20:46:41.283049Z",
"shell.execute_reply": "2026-03-25T20:46:41.282152Z",
"shell.execute_reply.started": "2026-03-25T20:45:42.426697Z"
}
},
"outputs": [],
"source": [
"bird_results = run_dataset_benchmarks(\n",
" birdclef2023_data, \n",
" birdclef2023_target, \n",
" n_runs=16, \n",
" kmeans_kwargs={\"n_clusters\":130}, \n",
" umap_hdbscan_kwargs={\n",
" \"min_samples\":5,\n",
" \"min_cluster_size\":100, \n",
" \"metric\":\"cosine\", \n",
" \"cluster_selection_method\":\"leaf\"\n",
" }\n",
")"
]
},
{
"cell_type": "markdown",
"id": "15082cc4-86ef-491d-affd-d066a88f9c23",
"metadata": {},
"source": [
"As always we'll start with timing results."
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "c2e9db88-dc9b-42cd-a2a7-7e442b6b57e8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:46:41.283929Z",
"iopub.status.busy": "2026-03-25T20:46:41.283731Z",
"iopub.status.idle": "2026-03-25T20:46:41.488356Z",
"shell.execute_reply": "2026-03-25T20:46:41.487783Z",
"shell.execute_reply.started": "2026-03-25T20:46:41.283914Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 18,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAMVCAYAAADqKmIJAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAWKFJREFUeJzt3Qd8VeX9P/Bv2EMSQGUJIu6BooJb69aiona5WlGrrdpa62752bpqpbWt1tbdOlq11rqtW+tGtIKgVnEBCipDQBKG7Pxfz+GfmJBE0QO5JHm/X6/zCuc555773Htzyf3cZxWVl5eXBwAAQA7N8twYAABAsAAAAFYILRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYALBKKyoqinvuuScaYz2feuqp7HYzZ85cafUCqC+CBQAFc/TRR2cfrJfdvv71rze6V2W33XaLU045pVrZjjvuGJMmTYqSkpKC1QtgRWmxwq4EQINUXl4eixcvjhYtCvMnIYWIG264oVpZ69atoylo1apVdOvWrdDVAFghtFgA5PgG+ic/+Un2LXSnTp2ia9euce2118acOXPimGOOiQ4dOsR6660XDz30ULXbvfHGG7HffvvFaqutlt3myCOPjGnTplUef/jhh2PnnXeOjh07xuqrrx4HHHBAjB07tvL4ggUL4qSTToru3btHmzZtYp111omhQ4dmx957773sG//Ro0dXnp+62aSy1O2mavebRx55JAYMGJB9iH/22WezgHHxxRfHuuuuG23bto1+/frFHXfcsdJ/P9L9pw/XVbf0fNblZz/7WWy44YbRrl27rK6//OUvY+HChZXHzzvvvNhyyy3jmmuuiV69emXnfec736nW3Sg9B9tuu220b98+e5532mmneP/99yuP//vf/47+/ftnz2+6j/PPPz8WLVpUefydd96Jr33ta9nxTTfdNB577LEvbJl5+umn47LLLqtslUmv1bJdoW688casPvfff39stNFGWd2//e1vZ79Tf/vb37LXOj036fcuhcGqvxNnnXVWrLXWWtlj2m677Spfb4D6IlgA5JA+7K2xxhrx3//+N/uwd+KJJ2YfYlMXl5dffjn23XffLDjMnTs3Oz91e9l1112zD74jRozIQsSUKVPikEMOqbxm+hB52mmnxUsvvRT/+c9/olmzZvGN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YAEAAOSmKxQAAJCbFgsAACA3wQIAAMityQWLNAlWmnPcZFgAALDiNLlgMWvWrGy++PQTAABYMZpcsAAAAFY8wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHJrkf8SAJDDpzMjRt4QMeGFiNW6Rgz4fkSPLT2lAA2MYAFA4cydEXH9vhHT3v6sbNTNEd++PmKzg70yAA2IrlAAFM5//1I9VCTliyMe/UXEkiWFqhUAX4FgAUDhvPds7eWlEyNmjKvv2gCQg2ABQOG0W7328qLmEW071ndtAMhBsACgcPofXXv5JoMi2q9R37UBIAfBAoDCWW/3iP3/ENG20/8vKIrY+ICIA//kVQFoYIrKy8vLowkpKyuLkpKSKC0tjeLi4kJXB4Bk4byIj8dEtO8SUbKW5wSgATLdLACF17JNRI+tCl0LAHLQFQoAAMhNiwUAhTfp1YgJw5euvL3RfhEtWhW6RgB8SYIFAIWTFsG790cRr9z6WVlxz4gj745Yc0OvDEADoisUAIWTAkXVUJGUfbA0bADQoAgWABTO63fVXv7BSxEzJ9Z3bQDIQbAAoHDKl3y1YwCscgQLAApnkwNrL+++ZUSn3vVdGwByECwAKJytjly60nZV7deMOOjyQtUIgK/IytsAFN77zy/dOnSP2OzgiFbtC10jAL4kwQIAAMhNVygAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAaNjB4plnnolBgwZFjx49oqioKO65554vvM0tt9wS/fr1i3bt2kX37t3jmGOOienTp9dLfQEAgFUwWMyZMycLCZdfvnwrrD733HMxePDgOPbYY+P111+P22+/PV566aU47rjjVnpdAQCAurWIAho4cGC2La8XXngh1llnnTj55JOz/T59+sTxxx8fF198cZ23mT9/frZVKCsry1lrAACgQY+x2HHHHeODDz6IBx98MMrLy2PKlClxxx13xP7771/nbYYOHRolJSWVW69eveq1zgB8geljI+75UcSftoq4fmDE/+70lAE0QEXl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U8U2ceLEeq0nAAA0BQ2yK1QKE8cee2zcfvvtsddee33uua1bt842AABg5WnWEFsqjj766PjHP/4R+++/f6GrAwAAFLrFYvbs2fHuu+9W7o8fPz5Gjx6dDcZee+21s25MH374Yfz973+vDBWDBw+Oyy67LLbffvuYPHlyVt62bdts/AQAANAEp5t96qmnsrUolnXUUUfFjTfemLVMvPfee9l5yW677RZPP/10necvD9PNAgBAI17Hor4IFgAAsOI1uDEWAADAqkewAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgMIFi3fffTceeeSR+PTTT7P98vLy/LUBAACaRrCYPn167LXXXrHhhhvGfvvtF5MmTcrKjzvuuDj99NNXRh0BAIDGFixOPfXUaNGiRUyYMCHatWtXWX7ooYfGww8/vKLrBwAANAAtvuwNHn300awLVM+ePauVb7DBBvH++++vyLoBAACNtcVizpw51VoqKkybNi1at269ouoFAAA05mDxta99Lf7+979X7hcVFcWSJUvid7/7Xey+++4run4AAEBj7AqVAsRuu+0WI0aMiAULFsRZZ50Vr7/+esyYMSOGDRu2cmoJAAA0rhaLTTfdNF599dXYdtttY++99866Rn3zm9+MUaNGxXrrrbdyagkAAKzSisqb2AIUZWVlUVJSEqWlpVFcXFzo6gAAQNPsCvXMM8984RgMAACgafnSLRbNmtXsPZUGcFdYvHhxrMq0WAAAwCowxuKTTz6ptk2dOjVbGG+bbbbJ1rgAAACani/dFSqNT1hWGsSd1rBIq3KPHDlyRdUNAABorC0WdVlzzTXjrbfeWlGXAwAAGnOLRZpqtqo0RGPSpEnxm9/8Jvr167ci6wYAADTWYLHllltmg7WXHfO9/fbbx/XXX78i6wYAADTWYDF+/Pgas0SlblBt2rRZkfUCAAAac7Do3bv3yqkJAADQuIPFn/70p+W+4Mknn5ynPgAAQGNdIK9Pnz7Ld7Giohg3blysyiyQBwAABWqxWHZcBQAAwEpZxwIAAGi6vvTg7eSDDz6I++67LyZMmBALFiyoduySSy5ZUXUDAAAaa7D4z3/+EwceeGA27iKttN23b9947733snUttt5665VTSwAAoHF1hRoyZEicfvrp8b///S9bu+LOO++MiRMnxq677hrf+c53Vk4tAQCAxhUsxowZE0cddVT27xYtWsSnn34aq622WlxwwQXx29/+dmXUEQAAaGzBon379jF//vzs3z169IixY8dWHps2bdqKrR0AANA4x1hsv/32MWzYsNh0001j//33z7pFvfbaa3HXXXdlxwAAgKbnSweLNOvT7Nmzs3+fd9552b9vu+22WH/99ePSSy9dGXUEAAAaw8rbVR1zzDHxve99L/bYY49spe2GxsrbAACwCoyxmD59etYFqmfPnlk3qNGjR6+EagEAAI06WKSF8SZPnhznnntujBw5Mvr375+Nt7jooouy9SwAAICm50t3haptFe5bb701rr/++njnnXdi0aJFsSrTFQoAAFaBFouqFi5cGCNGjIgXX3wxa63o2rXriqsZAADQuIPFk08+GT/4wQ+yIJEWy+vQoUP8+9//zlbgBgAAmp4vPd1sGrSdBnDvu+++cc0118SgQYOiTZs2K6d2AABA4wwW55xzTnznO9+JTp06rZwaAQAATW/wdkNj8DYAAKxig7cBAAAECwAAYIXQYgEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEDDDhbPPPNMDBo0KHr06BFFRUVxzz33fOFtnn766ejfv3+0adMm1l133bj66qvrpa4AAMAqGizmzJkT/fr1i8svv3y5zh8/fnzst99+scsuu8SoUaPi//7v/+Lkk0+OO++8c6XXFQAAqFtReXl5eawCUovF3XffHQcffHCd5/zsZz+L++67L8aMGVNZdsIJJ8Qrr7wSw4cPX677KSsri5KSkigtLY3i4uIVUncAAGjqGtQYixQe9tlnn2pl++67b4wYMSIWLlxY623mz5+fhYmqGwAA0ISDxeTJk6Nr167VytL+okWLYtq0abXeZujQoVkLRcXWq1eveqotAAA0HQ0qWFR0maqqoifXsuUVhgwZknV7qtgmTpxYL/UEAICmpEU0IN26dctaLaqaOnVqtGjRIlZfffVab9O6detsAwAAVp4G1WKxww47xGOPPVat7NFHH40BAwZEy5Yt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+7yVtWncBAsAAGrYfM3N44B1D6hRfuB6B2ZrTHRr363WZ233XqaQbap0hQIAoFZp1qcHxj0Qj773aERRxL7r7Bv79dkvm1b2uQ+fi1OePCVbSK/Cbr12i0t3uzSbmpamR7AAAOArmTxnctw/7v74ZN4n2craO6+1s7UsmjDBAgAAyM0YCwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyK1FNDHl5eXZz7KyskJXBQAAGoQOHTpEUVHR557T5ILFrFmzsp+9evUqdFUAAKBBKC0tjeLi4s89p6i84iv8JmLJkiXx0UcfLVfqouFJLVEpNE6cOPELf/mBVYv3LzRM3rtNQwctFjU1a9YsevbsWYCXg/qUQoVgAQ2T9y80TN67GLwNAADkJlgAAAC5CRY0Kq1bt45zzz03+wk0LN6/0DB579JkB28DAAArnhYLAAAgN8ECAADITbAAAAByEyyoN7vttluccsopnnEAgEZIsAAAaOKOPvroKCoqqrHtsccescYaa8SFF15Y6+2GDh2aHV+wYMFy3c+TTz4Z++23X6y++urRrl272HTTTeP000+PDz/8cAU/IgpBsAAAIL7+9a/HpEmTqm133nlnfO9734sbb7wxaptI9IYbbogjjzwyWrVq9YXP4DXXXBN77bVXdOvWLbvuG2+8EVdffXWUlpbGH/7wB69AIyBYUDAPP/xwlJSUxN///vfsm5KDDz44LrrooujatWt07Ngxzj///Fi0aFGceeaZ0blz5+jZs2dcf/311a6RvuE49NBDo1OnTtm3HwcddFC89957lcdfeuml2HvvvbNvU9J97brrrvHyyy9Xu0b6Ruavf/1rfOMb38i+Pdlggw3ivvvuqzz+ySefxHe/+91Yc801o23bttnx9B8psNQ666wTf/zjH6s9HVtuuWWcd955le+x9IHigAMOyN5jm2yySQwfPjzefffdrItk+/btY4cddoixY8dW3j79O72f0/8Hq622WmyzzTbx+OOP17jfX/3qV3HEEUdk5/To0SP+/Oc/e1kgx3oU6UN/1S39fT322GOz9+QzzzxT7fxnn3023nnnnez4kiVL4oILLsj+VqfrpP8D0t/5Ch988EGcfPLJ2Zb+lqf3fnoPf+1rX8v+Bp9zzjlet0ZAsKAg/vnPf8YhhxyShYrBgwdnZU888UR89NFH2X9cl1xySfahJH0QSf+pvfjii3HCCSdk28SJE7Pz586dG7vvvnv2gSLd5rnnnsv+nb5xqWiSnTVrVhx11FHZf34vvPBCFgpSE2wqryqFmFSfV199NTuegsSMGTOyY7/85S+zb1UeeuihGDNmTFx11VVZUAGWXwoA6b0+evTo2HjjjbMwcPzxx8eQIUNixIgR2TknnXRS5fmzZ8/O3ospTIwaNSr23XffGDRoUEyYMKHadX/3u9/FFltskX1hkK516qmnxmOPPealgRVo8803z8L9sl+qpYCw7bbbRt++feOyyy7LWh1+//vfZ39L03v2wAMPzIJHcvvtt2d/m88666xa7yN9oUgjkBbIg/qw6667lv/0pz8tv+KKK8pLSkrKn3jiicpjRx11VHnv3r3LFy9eXFm20UYble+yyy6V+4sWLSpv3759+a233prtX3fdddk5S5YsqTxn/vz55W3bti1/5JFHaq1DukaHDh3K//3vf1eWpbfBL37xi8r92bNnlxcVFZU/9NBD2f6gQYPKjznmmBX2PEBjk967l156abWyfv36lZ977rm1vseGDx+elaX3cIX0vm7Tps3n3s+mm25a/uc//7na/X7961+vds6hhx5aPnDgwNyPCZqa9He4efPm2d/ZqtsFF1yQHb/qqquy/VmzZmX76Wfav+aaa7L9Hj16lP/617+uds1tttmm/Ec/+lH27xNPPLG8uLi43h8X9UuLBfUq9alMM0M9+uijWWtDVZtttlk0a/bZr2TqApG+JanQvHnzrLvT1KlTs/2RI0dmXSk6dOiQtVSkLXWZmjdvXmWXinRuauXYcMMNs65QaUvfhC77rWf6xrNC6paRrllxPyeeeGLWwpKaddM3Lc8///xKenag8ar6Hkvv7aTq+zuVpfduWVlZtj9nzpzs/ZYGdqZvMtP7+80336zx3k1dqJbdTy2LwJeX/i6nVsWq249//OPs2OGHH551d7rtttuy/fQzfW9w2GGHZe/b1ONgp512qna9tF/xfkznpm6RNG4tCl0Bmpb04Tx1WUjNqalZtep/Mi1btqx2bjpWW1n6jy1JP/v37x+33HJLjftJ4yGSNHbj448/zvp/9+7dO+v3mT54LDt7xefdz8CBA+P999+PBx54IOuWseeee2b/0abmXiCyLwSWHdS5cOHCOt9jFe/72soq3ndpbNUjjzySvc/WX3/9bHzTt7/97eWaecaHF/hq0hdr6f1Wm/TFXHoPpr/faUxF+pn2i4uLK78QWPa9VzVMpC/40iDtNCC8e/fuXqJGSosF9Wq99dbLppq799574yc/+Umua2299dZZ380uXbpk/xFW3dJ/gEkaW5EGiqW+2qlFJAWLadOmfen7SkElhZSbb745CynXXnttrrpDY5LeH+nDQoX0IWP8+PG5rpneu+k9lyZVSC0baRBp1YkZKqSxU8vupzEcwIqXAsWwYcPi/vvvz36m/SSFizR5QhrrWFVq4U+TNSQphKSZoy6++OJarz1z5kwvWSOgxYJ6l761SOEizQjRokWLGrPJLK80wDoN3Ewzx1TMRJG6Sdx1113Zt51pP4WMm266KQYMGJB92Enl6ZvPLyPNVJFaRlIwmT9/fvYfasV/lEBk89ynqSjT4Oo02UKa8CB1XcwjvXfTezldM33jma5Z0ZpRVfpwkz6opFnl0qDtNEA0tS4CX176Gzd58uRqZenvdMWEJWlmxfTeTBMxpJ9pRqcK6e/rueeem32BmHonpBaN1JWqoldBr1694tJLL80maUh/j9M10qxQabaoNJFL6u5oytmGT7CgIDbaaKNsFqgULr7qB5A0bWWaDepnP/tZfPOb38xmelprrbWyrkrp25OKGSt++MMfxlZbbRVrr712Np3tGWec8aXuJ33DkmabSd+WplCyyy67ZGMugKXS+2PcuHHZLG6ptTDNAJW3xSJ9APn+978fO+64Y/ahJr3PK7pbVJUW1krjrdLMbmlsVPpgkmajAb68ND3sst2U0t/rNL6pQnpf/t///V8WJKpKvQPSezS9J9MYxTQ+Kk3dnmZjrPCjH/0o+3IxdXFMrZGffvppFi7S/x2nnXaal6wRKEojuAtdCQD4stIHkjQZRNoAKDxjLAAAgNwECwAAIDddoQAAgNy0WAAAALkJFgDUKc3c9mUHR6fpYe+5557s32k2tbSfpp0EoHETLAAAgNwECwAAIDfBAoDPlVa8Puuss6Jz587RrVu3OO+88yqPvfPOO9nqu23atMkWxEqrX9cmLbCVFrtL56VV7J966qnKY5988kl897vfjTXXXDNbhDItqJVW7a2QVuY97LDDsvtv3759DBgwIF588cXs2NixY+Oggw6Krl27Ziv3brPNNvH444/XWO8iLY6ZFvZKi+ilxTKvvfZarzrACiZYAPC5/va3v2Uf6NOH+YsvvjguuOCCLECkwJFWvW/evHm88MILcfXVV2crZNcmrdKbVuQdNWpUFjAOPPDAmD59enbsl7/8Zbzxxhvx0EMPxZgxY+Kqq67KVttOZs+eHbvuumt89NFH2Sq+r7zyShZy0n1XHN9vv/2yMJGunVbdHjRoUEyYMKHa/acVuVMgSeek1X9PPPHEaqsJA5Cf6WYB+NzB24sXL45nn322smzbbbeNPfbYI9vSh/o0QLtnz57ZsYcffjgGDhwYd999dxx88MHZsT59+sRvfvObytCxaNGirOwnP/lJFhJSyEhB4vrrr69x/6ll4Ywzzsiuk1oslkdqEUnB4aSTTqpssdhll13ipptuyvbLy8uzlpfzzz8/TjjhBK8+wAqixQKAz7XFFltU2+/evXtMnTo1a11I3YoqQkWyww471HqNquUtWrTIWg/S7ZMUAv75z3/GlltumQWN559/vvLcNJvUVlttVWeomDNnTnab1A2rY8eOWXeo1BKxbItF1ceQZqlKwSI9BgBWHMECgM/VsmXLavvpg3nqipS++V9WOra8Ks5NLRzvv/9+Nq1t6vK05557Zq0USRpz8XlSF6s777wzfv3rX2etKimIbL755rFgwYLlegwArDiCBQBfSWolSC0DKQxUGD58eK3npjEYFVJXqJEjR8bGG29cWZYGbh999NFx8803xx//+MfKwdWppSGFhRkzZtR63RQm0u2+8Y1vZIEitUSkblMA1D/BAoCvZK+99oqNNtooBg8enA2qTh/yzz777FrPveKKK7JxF6mb0o9//ONsJqg0S1NyzjnnxL333hvvvvtuvP7663H//ffHJptskh07/PDDs7CQxmsMGzYsxo0bl7VQVASY9ddfP+66664sfKQ6HHHEEVoiAApEsADgq/0BadYsCwvz58/PBnQfd9xxWZek2qTB27/97W+jX79+WQBJQaJi5qdWrVrFkCFDstaJNHVtmmUqjbmoOPboo49Gly5dsoHiqVUiXSudk1x66aXRqVOnbKapNBtUmhVq66239ooCFIBZoQAAgNy0WAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAsFzee++9KCoqitGjR68y97XbbrvFKaecstLrA8AXEywAWOX06tUrJk2aFH379s32n3rqqSxozJw5s9BVA6AOLeo6AACFsGDBgmjVqlV069bNCwDQgGixAKDSww8/HDvvvHN07NgxVl999TjggANi7NixdT5D9913X2ywwQbRtm3b2H333eNvf/tbjZaFO++8MzbbbLNo3bp1rLPOOvGHP/yh2jVS2YUXXhhHH310lJSUxA9+8INqXaHSv9O1k06dOmXl6dwKS5YsibPOOis6d+6chZHzzjuv2vXT+ddcc032WNq1axebbLJJDB8+PN59992sK1X79u1jhx12+NzHCcAXEywAqDRnzpw47bTT4qWXXor//Oc/0axZs/jGN76RfXhfVvrA/+1vfzsOPvjgLAAcf/zxcfbZZ1c7Z+TIkXHIIYfEYYcdFq+99lr2of+Xv/xl3HjjjdXO+93vfpd1e0rnp+PLdotK4SR56623si5Sl112WeXxFGZSOHjxxRfj4osvjgsuuCAee+yxatf41a9+FYMHD87qufHGG8cRRxyR1XfIkCExYsSI7JyTTjrJbwJAHuUAUIepU6eWpz8Vr732Wvn48eOzf48aNSo79rOf/ay8b9++1c4/++yzs3M++eSTbP+II44o33vvvaudc+aZZ5Zvuummlfu9e/cuP/jgg6uds+x9Pfnkk9WuW2HXXXct33nnnauVbbPNNlndKqTb/eIXv6jcHz58eFZ23XXXVZbdeuut5W3atPF7AJCDFgsAKqXuQOnb/HXXXTeKi4ujT58+WfmECRNqPEup9WCbbbapVrbttttW2x8zZkzstNNO1crS/jvvvBOLFy+uLBswYMBXfhW22GKLavvdu3ePqVOn1nlO165ds5+bb755tbJ58+ZFWVnZV64HQFNn8DYAlQYNGpR1PfrLX/4SPXr0yLpApS5KaUD1slJjQBq/sGzZlz0nSV2ZvqqWLVtW20/3t2zXrarnVNSntrLaunwBsHwECwAy06dPz1oY0kDnXXbZJSt77rnn6nx20liFBx98sFpZxXiFCptuummNazz//POx4YYbRvPmzZf7mU+zRCVVWzkAWLXoCgVA5YxLaSaoa6+9Npsx6YknnsgGctclDX5+880342c/+1m8/fbb8a9//atyUHZFC8Dpp5+eDQJPg6fTOWmg9eWXXx5nnHHGl3rWe/funV3z/vvvj48//jhmz57tVQNYxQgWACz9g9CsWfzzn//MZmZK3Z9OPfXUbLamuqTxF3fccUfcdddd2RiGq666qnJWqDS1bLL11ltngSNdN13znHPOyWZtqjpd7PJYa6214vzzz4+f//zn2XgIMzgBrHqK0gjuQlcCgMbh17/+dVx99dUxceLEQlcFgHpmjAUAX9mVV16ZzQyVulANGzYsa+HQmgDQNAkWAHxladrYtGr2jBkzYu21187GVKRF5wBoenSFAgAAcjN4GwAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACDy+n/qlqt3QCXcwwAAAABJRU5ErkJggg==",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" bird_results[bird_results.measure == \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" kind=\"swarm\", \n",
" col=\"measure\",\n",
" height=8,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "de1f4f88-09e3-4627-aac5-a56181a67ef8",
"metadata": {},
"source": [
"This time we have some slightly surprising results: KMeans is only barely faster than UMAP + HDBSCAN. This is a result of the need for a large number of clusters, combined with a smaller overall dataset size, such that UMAP + HDBSCAN can run very quickly. In combination this results in UMAP + HDBSCAN being only slightly slower than KMeans. On the other hand EVoC turned out results extremely quickly -- more than 3 times faster than KMeans. So for pure speed EVoC turns out to be a winner here.\n",
"\n",
"How about clustering quality?"
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "6a0814a6-36cb-45a8-90fd-fa2e9b25d45f",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:46:41.488922Z",
"iopub.status.busy": "2026-03-25T20:46:41.488756Z",
"iopub.status.idle": "2026-03-25T20:46:42.125507Z",
"shell.execute_reply": "2026-03-25T20:46:42.124937Z",
"shell.execute_reply.started": "2026-03-25T20:46:41.488907Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 19,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAPeCAYAAAARWnkoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAxxxJREFUeJzs3Qd8FNXexvEnhRACJPTeexUEpCkWVBAVwYoNy7W+elWwo9euF3sXrKhcGxZQvCrFRlWaoEjvNbQACTWQZN/P/+zdkE12Q8uQ9vu+n31lZ3Zmz87uzZlnTpkIn8/nEwAAAAAAyHOReb9LAAAAAABA6AYAAAAAwEO0dAMAAAAA4BFCNwAAAAAAHiF0AwAAAADgEUI3AAAAAAAeIXQDAAAAAOARQjcAAAAAAB4hdAMAAAAA4BFCN4DD9uijj6pt27Zhnxckp556qgYMGKCC6pprrlHfvn2LzPsAQFFAPZd/6tWrp5dffjlP97l7925deOGFio+PV0REhLZv367C4Ndffy1U5UV4hG4Amjp1qqKionTWWWcd0dG4++679dNPPxXKoPzBBx+4Ci3wqFq1qnr37q158+apIKDCBYCjRz0XoebNm+c4Lp9//rmr+yzoFpYL2kdyof/DDz/UpEmT3O8gMTFRCQkJKmhCHdOuXbsW2PLi8BC6gXzm8/mUlpaWr2UYNmyYbrvtNk2ePFmrV68+7O3LlCmjihUrqrCyK99Wqa1fv17fffeddu3apXPOOUf79u3L76IBQKFHPZf/SpcurU2bNum3337LUf/XqVNHRd2yZcvcRYdWrVqpWrVq7kLD4UpPT1dGRoaOpZiYmCMuLwoWQjcKLbsiaEHRrgqWL1/etVC+/fbbLjBde+21Klu2rBo2bKgffvghaLv58+fr7LPPdkHRtunfv7+2bNmSuX7MmDE66aSTVK5cORckzz33XPfHOsCC2D//+U9Vr15dsbGx7urw4MGD3bqVK1e6P4xz5szJfL11CbJl1mKZteVy7Nix6tChg0qWLOmuvtpJybPPPqsGDRqoVKlSatOmjb788kvPj6MdL7vS/X//93/us1rLb3ZPP/20O1Z2TK+77jrt3bs316vOoa7WWtdm6+IcMGTIEDVu3NgdQ9v3RRdd5JbbayZMmKBXXnkls/XZjuuhfHf2Wa666iq33r6fF1544ZCOgb2HVWq2jX0nAwcO1KpVq7Ro0aLM17z44otq3bq1O3GpXbu2brnlFu3cuTNzvR03+83Y92oVu5XBeg5YmM9aYd95552Zv617773Xfe+HI6/eJ7ffm60744wz3H4D29nv2E7MHnzwwcMqL4AjRz2XN6jnpOjoaF1++eUuZAesXbvWnZPY8oMNR7I63X6PudXTgfopq6+//jooMNr5VJ8+fVwdbvXXCSecoB9//PGovt9AeZ9//nlXj1u9d+utt2r//v1uvZXbzgcmTpzoyhL4HNu2bXPnDHYOGRcXp169emnJkiWZ+w18nv/+979q0aKFO1+zcwM773vyySczzzfq1q2rb775Rps3b3afzZbZ+cLMmTMz95WUlKTLLrtMtWrVcu9l6z/99NOgzxDqmIbq7fbVV1+pZcuWrjxWluznOrbs3//+t/7xj3+48zaru+38GPmL0I1CzboLVapUSdOnT3cB3ILjxRdf7Lrj/PHHH+rZs6cLZjaWx1gwOeWUU1xAtD+GFrA3btyoSy65JKhytsAyY8YM12U6MjJS559/fubVzVdffVWjR492QdVC2UcffXTY3bKMBSEL6wsWLNBxxx2nf/3rX3r//fc1dOhQ17XZgt+VV17p/giHc/PNN7s/7rk9DtZyPWLECDVt2tQ97P2sDFkDmn3ORx55RE899ZQ7ZlahWWA+Graf22+/XY8//rg7hvY9nHzyyW6dVThdunTRDTfc4L4ve1jIPZTv7p577tEvv/yiUaNGady4ca6ymjVr1mGVzSq2Tz75xP27RIkSmcvtd2Df/d9//+1+dz///LP7DrOy35lV+v/5z39c5W7H3rreB1jFaCc87733nutVsHXrVlfWw5UX75Pb780qePuM9r8r+8yB35qdJNkFFgDHDvUc9Vxe1XN20dzq/MA5kYVKu7hqf9sPR7h6+lDYxWq7eG5Be/bs2e48zYZ0HUkvu6zsmFigt//a/2bsswUaEUaOHOnKamW2strzQNC18wk7p7MeAHbuY2ULhHVjx8rO1d59911XV1apUsUtf+mll3TiiSe6z2A94+xc00K41aN2/tmoUSP3PHA+ZY0V7du3dwHeziNuvPFGt820adMO65jad22/h0svvVRz5851dfJDDz2Uo8HEzgOsEcHKZ40Edn68cOHCozrGOEo+oJA65ZRTfCeddFLm87S0NF/p0qV9/fv3z1yWmJho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QoUKuRagq6++Wps3b/ZtX7BggTp27OhalOy12rZtq8WLF/sdw1ql3nvvPV1zzTWuBalatWr69ttvfdv379+vm266ScWKFVNkZKTbbn8wI+urWLGiXnvtNb91DRs21ODBg30/ews0V155pfvZ16pVS3PnztX69etd1818+fKpZcuW2rBhg+/59n/7ntn3NH/+/GrWrJmmT5+e5nWffvpp9ezZ0+1TunRpvfHGGxn0rpEZ05RYIE75sN9Jd9xxh/u+/PLLL377z5o1S+vWrXPbk5KSNGTIEPf7zY5j30/73ei1bds29e3b1z3s9599L+37dckll7jfW0888QQ/cDhcU5ERuK4i2LimZh2E7gvA2LFjdf3117vAfeutt7p1M2fO1I4dO9wfqK+88ooLRhaG7I/X+fPnq0+fPu6xdetWt/+RI0fUrl07F2rsOb/++qv7v90h83bZPHjwoHr16uX+yJ03b54LzNZF09anZAHfzuePP/5w2y1k79u3z217/PHHXcvS5MmTtWrVKg0fPtyFeFwYLBzbd3Dp0qWqWbOmC8p33XWXBg0apIULF7p97rvvPt/+hw4dct8RC9pLlixRp06d1LVrV8XExPgd96WXXlL9+vXdTR471gMPPKBp06Zl+PtD5qlXr567KZP6Jp2F5+bNm6tu3bp6/fXXXWv1yy+/7H7/2PfpqquucqHcfPnll+732YABAwK+ht2gBLimIivhuopg4JqaCWyebmQ/bdu29fznP//xvPXWW57o6GjPzJkzfdt69erlqVChgicxMdG3rkaNGp42bdr4lk+ePOnJly+fZ8yYMW75/fffd/skJSX59jl+/LgnMjLSM2XKlIDnYMcoUKCA57vvvvOts6/Uf//7X9/yoUOHPCEhIZ7Jkye75a5du3puu+228/Y5IOPYd+rVV1/1W9egQQPPk08+GfBnP3fuXLfOvlte9n2LiIg47evUrl3b88Ybb/i9bufOnf326dGjh6dLly7n/J6QtdjvrtDQUPe7KeVjyJAhbvvw4cPd8sGDB92y/WvLI0aMcMulS5f2PPPMM37HbNasmeeee+5x/7/77rs9UVFRGf6+kPVxTUVm4LqKYOKamrXQ0p2N2XhEq2A+depU10qdUp06dZQr16kfr3XftbtaXqGhoa4LeWxsrFtetGiR6wZcoEAB18JtD+uGfuzYMV93YNvX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",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" bird_results[bird_results.measure != \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" col=\"measure\", \n",
" kind=\"swarm\", \n",
" col_wrap=2,\n",
" height=5,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "368d1989-4016-47e0-b8eb-ccfed6a90b18",
"metadata": {},
"source": [
"The first thing to note here is how hard it is o cluster this dataset well. Both ARI and AMI scores in the 0 to 0.6 range represent a poor match against the ground-truth labels. This was a recent (2023) Kaggle challenge dataset though, so we should expect it to be very hard -- and we are trying to get results in an entirely unsupervised fashion. With the relative poor quality overall out of the way, how do the different methods compare? Here EVoC is a clear winner in pure ARI and AMI quality. It got there though by clustering even less of the data than UMAP + HDBSCAN. In fact it often clustered less than half the data. We would rather have clusters that are good, and leave hard to cluster points unclustered than have things forced into clusters just to get good coverage, so we don't necessarily see this as bad. Even accounting for the difference in amount clustered using the clustering score EVoC still comes out ahead. This shows how these algorithms perform when the going gets rough."
]
},
{
"cell_type": "markdown",
"id": "478fc28c-48fa-4a4a-b45d-7dd94ecd8cd8",
"metadata": {},
"source": [
"## Other high dimensional data\n",
"\n",
"Sometimes you just have high dimensional data that isn't necessarily directly from some neural embedding model. We can still try to cluster it and see if EVoC can do a decent job. To try this out let's use the classic MNIST digits dataset. We know that we *should* be able to find good clusters for this data even just using the raw pixel values. So let's see how we do with EVoC on MNIST data."
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "3d3f6754-d952-45fd-b96b-1644c141b26b",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:46:42.126177Z",
"iopub.status.busy": "2026-03-25T20:46:42.126019Z",
"iopub.status.idle": "2026-03-25T20:46:42.142323Z",
"shell.execute_reply": "2026-03-25T20:46:42.141841Z",
"shell.execute_reply.started": "2026-03-25T20:46:42.126164Z"
}
},
"outputs": [],
"source": [
"from sklearn.datasets import fetch_openml"
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "0abebd46-634b-45ed-96e3-beb7ba44d5f6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:46:42.142955Z",
"iopub.status.busy": "2026-03-25T20:46:42.142804Z",
"iopub.status.idle": "2026-03-25T20:46:45.795859Z",
"shell.execute_reply": "2026-03-25T20:46:45.795225Z",
"shell.execute_reply.started": "2026-03-25T20:46:42.142942Z"
}
},
"outputs": [],
"source": [
"mnist_ds = fetch_openml('mnist_784')\n",
"mnist_data = mnist_ds.data.values.astype(np.float32, order=\"C\")\n",
"mnist_target = mnist_ds.target.values.astype(np.uint8)"
]
},
{
"cell_type": "markdown",
"id": "2e5aeed4-a19a-410c-a580-8aa7fe515388",
"metadata": {},
"source": [
"We can run the benchmarks. This time parameter tuning is a little easier."
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "7eebb0b3-c2df-4e64-ba75-6594da5382f3",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:46:45.796609Z",
"iopub.status.busy": "2026-03-25T20:46:45.796456Z",
"iopub.status.idle": "2026-03-25T20:51:16.994356Z",
"shell.execute_reply": "2026-03-25T20:51:16.993592Z",
"shell.execute_reply.started": "2026-03-25T20:46:45.796595Z"
}
},
"outputs": [],
"source": [
"mnist_results = run_dataset_benchmarks(\n",
" mnist_data, \n",
" mnist_target, \n",
" n_runs=16, \n",
" kmeans_kwargs={\"n_clusters\":10}, \n",
" umap_hdbscan_kwargs={\n",
" \"min_samples\":5,\n",
" \"min_cluster_size\":1200, \n",
" \"metric\":\"cosine\", \n",
" \"cluster_selection_method\":\"leaf\"\n",
" }\n",
")"
]
},
{
"cell_type": "markdown",
"id": "23339d19-8803-4f06-806b-474a32e0f1a9",
"metadata": {},
"source": [
"As always let's begin with time taken to compute the clusterings:"
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "6eb39efd-0a6c-4505-b9ed-c369210056f2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:51:16.995048Z",
"iopub.status.busy": "2026-03-25T20:51:16.994885Z",
"iopub.status.idle": "2026-03-25T20:51:17.193178Z",
"shell.execute_reply": "2026-03-25T20:51:17.192628Z",
"shell.execute_reply.started": "2026-03-25T20:51:16.995034Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 23,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAMVCAYAAADqKmIJAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAUPxJREFUeJzt3Qd4VeX9B/Bf2DNhiCLKcoAibnEr7lVx1W1FrbXWvaqWWmer1FFHa+to66hWba3buitOcICzigqIgAqigEzZ+T/v4Z+YkEDRQyafz/PcJznvOffcN/fkJvd731VQXFxcHAAAADk0yHNnAAAAwQIAAFgutFgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAUCtVFBQEA8++GDUx3o+99xz2f2+/vrrKqsXQHUTLACodkcffXT2xnrx2x577FHvrsYOO+wQp59+ermyrbfeOsaPHx9FRUU1Vi+A5a3Rcj8jAHVCcXFxLFiwIBo1qpl/BSlE3HrrreXKmjZtGiuCJk2aRMeOHWu6GgDLlRYLgO/xCfQpp5ySfQrdtm3bWGWVVeLmm2+OmTNnxjHHHBOtW7eONddcMx5//PFy93v//fdjr732ilatWmX3OfLII+Orr74q3f/EE0/EtttuG23atIn27dvH3nvvHaNGjSrdP3fu3Dj55JNj1VVXjWbNmkW3bt1i4MCB2b5PPvkk+8T/rbfeKj0+dbNJZanbTdnuN08++WRsttlm2Zv4F198MQsYV1xxRayxxhrRvHnz2HDDDeNf//pXlf9epMdPb67L3tLzuSTnnntu9OjRI1q0aJHV9fzzz4958+aV7r/oootio402iptuuik6d+6cHXfQQQeV626UnoPNN988WrZsmT3P22yzTYwZM6Z0/yOPPBKbbrpp9vymx7j44otj/vz5pftHjBgR22+/fba/V69e8fTTT//Plpnnn38+rrvuutJWmXStFu8Kddttt2X1efTRR6Nnz55Z3Q888MDsd+r222/PrnV6btLvXQqDZX8nzjnnnFhttdWyn2mLLbYovd4A1U2wAPge0pu9lVZaKV577bXszd4JJ5yQvYlNXVzeeOON2H333bPgMGvWrOz41O2lb9++2RvfoUOHZiHiiy++iIMPPrj0nOlN5Jlnnhmvv/56/Oc//4kGDRrE/vvvHwsXLsz2//73v4+HH344/vnPf8aHH34Yd955Z/aG87tKb0RTIBk+fHhssMEG8atf/SprObjhhhvivffeizPOOCN+9KMfZW+Il+RnP/tZFpCWdhs7duxy/d1KgS29AU8BLb1R//Of/xzXXHNNuWNGjhyZPT8pIKTnOAWtk046KduXAsJ+++2XXYd33nknhgwZEj/96U+zN/hJClzp5z711FOzx0gBJT3epZdemu1P1+GAAw6Ihg0bxiuvvBI33nhjFnaWJtVzq622iuOOOy77HUi3FHoqk35X0jW+5557srqngJAe77HHHstud9xxRxZgy4a+FGRffvnl7D7pZ0q/g6klKAUggGpXDMB30rdv3+Jtt922dHv+/PnFLVu2LD7yyCNLy8aPH1+c/sQOGTIk2z7//POLd9ttt3LnGTduXHbMhx9+WOnjTJw4Mdv/7rvvZtunnHJK8U477VS8cOHCCseOHj06O/bNN98sLZsyZUpWNmjQoGw7fU3bDz74YOkxM2bMKG7WrFnx4MGDy53v2GOPLT7ssMOW+Bx88cUXxSNGjFjqbd68eUu8/1FHHVXcsGHD7Hkre7vkkktKj0l1feCBB5Z4jiuuuKJ40003Ld2+8MILs3Om57XE448/XtygQYPsekyaNCk753PPPVfp+bbbbrviyy67rFzZHXfcUbzqqqtm3z/55JOVnv9/1TP9vpx22mnlykquRbpGya233pptjxw5svSY448/vrhFixbF06dPLy3bfffds/IkHVtQUFD82WeflTv3zjvvXDxgwIAl1gegqhhjAfA9pE/6S6RPsFPXpfXXX7+0LHV1SiZOnJh9HTZsWAwaNCj7JH9xqbtT6uKTvqbuPenT8NRFqqSlIn3y37t376xbza677pp1lUmfSqeuUrvtttt3rnvqBlUifTI/e/bs7LxlpS42G2+88RLPsfLKK2e3PHbccceslaSsdu3aLfH49En9tddem7VKzJgxI2uBKCwsLHdMly5dYvXVVy/dTq0F6XlMLTyppSI9h6k1Kf28u+yyS9ZilLqWlVyj1FpU0kKRpG5H6flJrQmphaey8y8vqftT6kJX9ncotUiV/Z1JZSW/U6llLOWv9LtT1pw5c7LfR4DqJlgAfA+NGzcut52605QtK+leUxIO0td+/frF5ZdfXuFcJW9s0/7UTSZ18enUqVN2nxQo0pv8ZJNNNonR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+bY8tVosydghrgpAHWNWKACqzqfDIu46OGLWV4u201oV254ZsfP5Ea1XXfL9lrYPgFpJiwUAVWPhwkWDsktCRZK6P714VcTHz0f0/mFE83YV79e2e8Tau7oqAHWMYAFA1fhsaMTXYyrf99/7IpoVRhz5QMSqG35b3nWbiCPvXzQOA4A6RVcoAKpu9qel7fvgsUWtF1+NjFh5vYgtjo/Y9ChXA6COEiwAqBqr94lo1TFixoSK+wo7RdxzeOobtWh74nsRj5wa0bxNRK99XRGAOkhXKACqRsNGEfvfENG4ZfnyTY6K+HjQt6GirBd/52oA1FFaLACoOmvuFHH6u4vGVMz+OmKtnSNW2zRiYOfKj//yQ1cDoI4SLACoWi3bR2zx0/JlHXpGfPp6xWNTOQB1kq5QAFS/7c5Ki1osoRyAukiwAKD69dwz4tC7IlbbbNEYjE4bRxx8h4HbAHVYQXFxcSWj5+qPadOmRVFRUUydOjUKCwtrujoAAFAvabEAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAECwAAICap8UCAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgLodLF544YXo169fdOrUKQoKCuLBBx8st7+4uDguuuiibH/z5s1jhx12iPfee6/G6gsAANTCYDFz5szYcMMN4/rrr690/xVXXBFXX311tv/111+Pjh07xq677hrTp0+v9roCAABLVlCcmgVqgdRi8cADD8R+++2XbadqpZaK008/Pc4999ysbM6cObHKKqvE5ZdfHscff3yl50nHpFuJadOmRefOnWPq1KlRWFhYTT8NAACsWGrtGIvRo0fHhAkTYrfddista9q0afTt2zcGDx68xPsNHDgwioqKSm8pVAAAACtosEihIkktFGWl7ZJ9lRkwYEDWOlFyGzduXJXXFQAAVnSNopZLXaTKSl2kFi8rK7VqpBsAAFB9am2LRRqonSzeOjFx4sQKrRgAAEDNqrXBonv37lm4ePrpp0vL5s6dG88//3xsvfXWNVo3AACgFnWFmjFjRowcObLcgO233nor2rVrF126dMlmhLrsssti7bXXzm7p+xYtWsThhx9ek9UGAABqU7AYOnRo7LjjjqXbZ555Zvb1qKOOittuuy3OOeec+Oabb+LEE0+MKVOmxBZbbBFPPfVUtG7dugZrDQAA1Np1LKpKWsciTTtrHQsAAFgBx1gAAAB1h2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAIBgAQAA1DwtFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABAADkJlgAAAC5CRYAAEBuggUAAFBzwWLkyJHx5JNPxjfffJNtFxcX568NAACwYgSLSZMmxS677BI9evSIvfbaK8aPH5+V/+QnP4mzzjqrKuoIAADUt2BxxhlnRKNGjWLs2LHRokWL0vJDDjkknnjiieVdPwAAoA5o9F3v8NRTT2VdoFZfffVy5WuvvXaMGTNmedYNAACory0WM2fOLNdSUeKrr76Kpk2bLq96AQAA9TlYbL/99vG3v/2tdLugoCAWLlwYV155Zey4447Lu34AAEB97AqVAsQOO+wQQ4cOjblz58Y555wT7733XkyePDlefvnlqqklAABQv1osevXqFe+8805svvnmseuuu2Zdow444IB48803Y80116yaWgIAALVaQXE9X4Bi2rRpUVRUFFOnTo3CwsKarg4AANRL37kr1AsvvPA/x2AAAAArlu/cYtGgQcXeU2kAd4kFCxZEbaLFAgAAauEYiylTppS7TZw4MVsYr0+fPtkaFwAAwIrnO3eFSuMVFpcGcac1LNKq3MOGDVtedQMAAOpri8WSdOjQIT788MPldToAAKA+t1ikqWbLSkM0xo8fH7/97W9jww03XJ51AwAA6muw2GijjbLB2ouP+d5yyy3jlltuWZ51AwAA6muwGD16dIVZolI3qGbNmi3PegEAAPU5WHTt2rVqagIAANTvYPH73/9+mU946qmn5qkPAABQXxfI6969+7KdrKAgPv7446hNLJAHAAC1pMVi8XEVAAAAVbKORVWYP39+/OpXv8paTJo3bx5rrLFGXHLJJbFw4cKarhoAAJBn8Hby6aefxsMPPxxjx46NuXPnltt39dVXx/Jy+eWXx4033hi33357rLfeejF06NA45phjstW/TzvttOX2OAAAQDUHi//85z+xzz77ZK0IaaXt3r17xyeffJKta7HJJpvE8jRkyJDYd9994wc/+EG23a1bt7j77ruzgAEAANThrlADBgyIs846K/773/9ma1fcd999MW7cuOjbt28cdNBBy7Vy2267bRZkPvroo2z77bffjpdeein22muvJd5nzpw52YDtsjcAAKCWBYvhw4fHUUcdlX3fqFGj+Oabb6JVq1bZ2IfUdWl5Ovfcc+Owww6LddZZJxo3bhwbb7xxnH766VnZkgwcODDrKlVy69y583KtEwAAsByCRcuWLbNWgaRTp04xatSo0n1fffVVLE//+Mc/4s4774y77ror3njjjWysxVVXXZV9XVqLytSpU0tvqTUFAACoZWMsttxyy3j55ZejV69e2diH1C3q3Xffjfvvvz/btzydffbZ8Ytf/CIOPfTQbHv99dePMWPGZK0SJa0mi2vatGl2AwAAanGwSLM+zZgxI/v+oosuyr5PLQtrrbVWXHPNNcu1crNmzYoGDco3qjRs2NB0swAAUNeDxa9//ev40Y9+lM0C1aJFi/jTn/5UNTWLiH79+sWll14aXbp0yaabffPNN7Ng8+Mf/7jKHhMAAPjuCopTQvgO0lSzTz31VLRv3z7r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AIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByEywAAIDcBAsAACA3wQIAAMhNsAAAAHITLAAAgNwECwAAIDfBAgAAyE2wAAAAchMsAACA3AQLAAAgN8ECAADITbAAAAByaxT1XHFxcfZ12rRpNV0VAACok1q3bh0FBQUrdrCYPn169rVz5841XRUAAKiTpk6dGoWFhUs9pqC45CP9emrhwoXx+eefL1PKom5KrVEpOI4bN+5//sIDtY/XMNRtXsMrhtZaLCIaNGgQq6++ek1fC6pBChWCBdRdXsNQt3kNY/A2AACQm2ABAADkJlhQ5zVt2jQuvPDC7CtQ93gNQ93mNcwKM3gbAACoelosAACA3AQLAAAgN8ECAADITbCgyuywww5x+umne4YBAFYAggUAwArs6KOPjoKCggq3nXbaKVZaaaX4zW9+U+n9Bg4cmO2fO3fuMj3OoEGDYq+99or27dtHixYtolevXnHWWWfFZ599tpx/ImqKYAEAsILbY489Yvz48eVu9913X/zoRz+K2267LSqbRPTWW2+NI488Mpo0afI/z3/TTTfFLrvsEh07dszO+/7778eNN94YU6dOjd/97ndV9FNR3QQLqs0TTzwRRUVF8be//S37dGS//faLyy67LFZZZZVo06ZNXHzxxTF//vw4++yzo127drH66qvHLbfcUu4c6VONQw45JNq2bZt94rHvvvvGJ598Urr/9ddfj1133TX7BCU9Vt++feONN94od470Kcxf/vKX2H///bNPTNZee+14+OGHS/dPmTIljjjiiOjQoUM0b94825/+eALldevWLa699tpyZRtttFFcdNFFpa+19GZi7733zl5r6667bgwZMiRGjhyZdZVs2bJlbLXVVjFq1KjS+6fv0+s6/V1o1apV9OnTJ5555pkKj/vrX/86Dj/88OyYTp06xR/+8AeXB3KuRZHe9Je9pf+1xx57bPa6fOGFF8od/+KLL8aIESOy/QsXLoxLLrkk+7+dzpP+DqT/+SU+/fTTOPXUU7Nb+r+eXv/pdbz99ttn/48vuOAC166eECyoFvfcc08cfPDBWajo379/Vvbss8/G559/nv2xuvrqq7M3I+kNSPpD9uqrr8bPfvaz7DZu3Ljs+FmzZsWOO+6YvZFI93nppZey79OnLCXNsNOnT4+jjjoq+4P3yiuvZKEgNbum8rJSiEn1eeedd7L9KUhMnjw523f++ednn6Q8/vjjMXz48LjhhhuyoAJ8dykApNf8W2+9Feuss04WBo4//vgYMGBADB06NDvm5JNPLj1+xowZ2WsyhYk333wzdt999+jXr1+MHTu23HmvvPLK2GCDDbIPDtK5zjjjjHj66addIljO1l9//SzgL/4BWwoIm2++efTu3Tuuu+66rNXhqquuyv6vptftPvvskwWP5N57783+T59zzjmVPkb6cJF6Ii2QB1Whb9++xaeddlrxH//4x+KioqLiZ599tnTfUUcdVdy1a9fiBQsWlJb17NmzeLvttivdnj9/fnHLli2L77777mz7r3/9a3bMwoULS4+ZM2dOcfPmzYuffPLJSuuQztG6deviRx55pLQs/dr/6le/Kt2eMWNGcUFBQfHjjz+ebffr16/4mGOOWW7PA9RX6TV8zTXXlCvbcMMNiy+88MJKX2tDhgzJytJruUR6fTdr1mypj9OrV6/iP/zhD+Ued4899ih3zCGHHFK855575v6ZYEWU/ic3bNgw+59b9nbJJZdk+2+44YZse/r06dl2+pq2b7rppmy7U6dOxZdeemm5c/bp06f4xBNPzL4/4YQTigsLC6v956L6abGgSqV+lGlmqKeeeiprbShrvfXWiwYNvv0VTF0f0icjJRo2bJh1d5o4cWK2PWzYsKwLRevWrbOWinRLXaZmz55d2pUiHZtaOXr06JF1hUq39Ano4p92pk86S6TuGOmcJY9zwgknZC0sqSk3fboyePDgKnp2oP4r+1pLr/Gk7Os8laXX8LRp07LtmTNnZq+7NKgzfYqZXucffPBBhddw6kK1+HZqYQS+n/Q/OrUslr2ddNJJ2b7DDjss6+70j3/8I9tOX9NnB4ceemj22k29D7bZZpty50vbJa/JdGzqGkn916imK0D9lt6cp64KqQk1NaWW/cPSuHHjcsemfZWVpT9mSfq66aabxt///vcKj5PGQyRp7MaXX36Z9fvu2rVr1tczveFYfMaKpT3OnnvuGWPGjIl///vfWXeMnXfeOfvjmpp4gW+lDwYWH9A5b968Jb7WSl7/lZWVvP7SGKsnn3wye72ttdZa2TinAw88cJlmnfHGBb6/9CFbes1VJn1Il16H6X95GlORvqbtwsLC0g8FFn/9lQ0T6cO+NEg7DQhfddVVXaZ6TIsFVWrNNdfMppd76KGH4pRTTsl1rk022STrr7nyyitnf/zK3tIfvSSNrUiDw1If7dQikoLFV1999Z0fKwWVFFLuvPPOLKTcfPPNueoO9VF6naQ3CiXSG4zRo0fnOmd6DafXXppcIbVspAGkZSdoKJHGUC2+ncZwAFUjBYqXX345Hn300exr2k5SuEgTKKRxj2Wl1v40YUOSQkiaOeqKK66o9Nxff/21y1ZPaLGgyqVPKlK4SLNANGrUqMIsMssqDbBOAzbTjDEls0+k7hH3339/9iln2k4h44477ojNNtsse5OTytMnnt9Fmp0itYykYDJnzpzsj2jJH0fgW2mO+zQNZRpcnSZdSBMfpC6MeaTXcHpNp3OmTzvTOUtaM8pKb2zSm5Q0u1watJ0Gh6ZWRuD7Sf/vJkyYUK4s/c8umbwkzbKYXp9pMob0Nc3oVCL9r73wwguzDxNTT4XUopG6UpX0MOjcuXNcc8012UQN6X9zOkeaFSrNFpUmdUldHk05Wz8IFlSLnj17ZrNApXDxfd94pOkq02xQ5557bhxwwAHZTE+rrbZa1lUpfWJSMkvFT3/609h4442jS5cu2XS2P//5z7/T46RPVdIsM+lT0hRKtttuu2zMBVBeep18/PHH2WxuqdUwzQCVt8Uivfn48Y9/HFtvvXX2hia93ku6WpSVFtVK467SDG9pjFR6U5JmogG+nzQ97OLdlNL/7jTGqUR6bf7yl7/MgkRZqadAep2m12Uar5jGSKVp3NPMjCVOPPHE7IPG1M0xtUh+8803WbhIfz/OPPNMl62eKEgjuGu6EgCwrNKbkTQpRLoBUHsYYwEAAOQmWAAAALnpCgUAAOSmxQIAAMhNsACgnDR723cdGJ2mhn3wwQez79OMamk7TTcJwIpDsAAAAHITLAAAgNwECwAqSKtdn3POOdGuXbvo2LFjXHTRRaX7RowYka2626xZs2whrLTydWXSwlppobt0XFrJ/rnnnivdN2XKlDjiiCOiQ4cO2UKUaSGttFpvibQi76GHHpo9fsuWLWOzzTaLV199Nds3atSo2HfffWOVVVbJVuzt06dPPPPMMxXWukgLZKYFvdICemnBzJtvvtmVBqhCggUAFdx+++3ZG/r0Zv6KK66ISy65JAsQKXCkle8bNmwYr7zyStx4443Z6tiVSavzppV433zzzSxg7LPPPjFp0qRs3/nnnx/vv/9+PP744zF8+PC44YYbspW2kxkzZkTfvn3j888/z1bvffvtt7OQkx67ZP9ee+2VhYl07rTidr9+/WLs2LHlHj+txp0CSTomrfp7wgknlFtFGIDly3SzAFQYvL1gwYJ48cUXS8s233zz2GmnnbJbelOfBmivvvrq2b4nnngi9txzz3jggQdiv/32y/Z17949fvvb35aGjvnz52dlp5xyShYSUshIQeKWW26p8OynloWf//zn2XlSi8WySC0iKTicfPLJpS0W2223Xdxxxx3ZdnFxcdbycvHFF8fPfvYzVxygCmixAKCCDTbYoNz2qquuGhMnTsxaF1K3opJQkWy11VaVPoNlyxs1apS1HqT7JykE3HPPPbHRRhtlQWPw4MGlx6bZpDbeeOMlhoqZM2dm90ndsNq0aZN1h0otEYu3WJT9GdIsVSlYpJ8BgKohWABQQePGjcttpzfmqStS+uR/cWnfsio5NrVwjBkzJpvWNnV52nnnnbNWiiSNuVia1MXqvvvui0svvTRrVUlBZP3114+5c+cu088AQNUQLABYZqmVILUMpDBQYsiQIZUem8ZglEhdoYYNGxbrrLNOaVkauH300UfHnXfeGddee23p4OrU0pDCwuTJkys9bwoT6X77779/FihSS0TqNgVAzRIsAFhmu+yyS/Ts2TP69++fDapOb/LPO++8So/94x//mI27SN2UTjrppGwmqDRLU3LBBRfEQw89FCNHjoz33nsvHn300Vh33XWzfYcddlgWFtJ4jZdffjk+/vjjrIWiJMCstdZacf/992fhI9Xh8MMP1xIBUAsIFgAs+z+NBg2ysDBnzpxsQPdPfvKTrEtSZdLg7csvvzw23HDDLICkIFEy81OTJk1iwIABWetEmro2zTKVxlyU7Hvqqadi5ZVXzgaKp1aJdK50THLNNddE27Zts5mm0mxQaVaoTTbZxFUEqGFmhQIAAHLTYgEAAOQmWAAAALkJFgAAQG6CBQAAkJtgAQAA5CZYAAAAuQkWAABAboIFAACQm2ABwFJ98sknUVBQEG+99VateawddtghTj/99CqvDwDLTrAAoNbo3LlzjB8/Pnr37p1tP/fcc1nQ+Prrr2u6agD8D43+1wEAUB3mzp0bTZo0iY4dO3rCAeogLRYAxBNPPBHbbrtttGnTJtq3bx977713jBo1aonPzMMPPxxrr712NG/ePHbccce4/fbbK7Qs3HfffbHeeutF06ZNo1u3bvG73/2u3DlS2W9+85s4+uijo6ioKI477rhyXaHS9+ncSdu2bbPydGyJhQsXxjnnnBPt2rXLwshFF11U7vzp+Jtuuin7WVq0aBHrrrtuDBkyJEaOHJl1pWrZsmVstdVWS/05AVh2ggUAMXPmzDjzzDPj9ddfj//85z/RoEGD2H///bM374tLb/gPPPDA2G+//bIAcPzxx8d5551X7phhw4bFwQcfHIceemi8++672Zv+888/P2677bZyx1155ZVZt6d0fNq/eLeoFE6SDz/8MOsidd1115XuT2EmhYNXX301rrjiirjkkkvi6aefLneOX//619G/f/+snuuss04cfvjhWX0HDBgQQ4cOzY45+eST/QYALA/FALCYiRMnFqd/Ee+++27x6NGjs+/ffPPNbN+5555b3Lt373LHn3feedkxU6ZMybYPP/zw4l133bXcMWeffXZxr169Sre7du1avN9++5U7ZvHHGjRoULnzlujbt2/xtttuW66sT58+Wd1KpPv96le/Kt0eMmRIVvbXv/61tOzuu+8ubtasmesPsBxosQAg6w6UPs1fY401orCwMLp37549K2PHjq3w7KTWgz59+pQr23zzzcttDx8+PLbZZptyZWl7xIgRsWDBgtKyzTbb7Hs/+xtssEG57VVXXTUmTpy4xGNWWWWV7Ov6669frmz27Nkxbdq0710PABYxeBuA6NevX9b16M9//nN06tQp6wKVuiilAdWLS40BafzC4mXf9ZgkdWX6vho3blxuOz3e4l23yh5TUp/Kyirr8gXAdyNYAKzgJk2alLUwpIHO2223XVb20ksvLfH4NFbhscceK1dWMl6hRK9evSqcY/DgwdGjR49o2LDhMtctzRKVlG3lAKB20hUKYAWXZlxKM0HdfPPN2YxJzz77bDaQe0nS4OcPPvggzj333Pjoo4/in//8Z+mg7JIWgLPOOisbBJ4GT6dj0kDr66+/Pn7+859/p7p17do1O+ejjz4aX375ZcyYMSPnTwtAVREsAFZwaQaoe+65J5uZKXV/OuOMM7LZmpYkjb/417/+Fffff382huGGG24onRUqTS2bbLLJJlngSOdN57zggguyWZvKThe7LFZbbbW4+OKL4xe/+EU2HsIMTgC1V0EawV3TlQCgbrv00kvjxhtvjHHjxtV0VQCoIcZYAPCd/elPf8pmhkpdqF5++eWshUNrAsCKTbAA4DtL08amVbMnT54cXbp0ycZUpEXnAFhx6QoFAADkZvA2AACQm2ABAADkJlgAAAC5CRYAAEBuggUAAJCbYAEAAOQmWAAAALkJFgAAQOT1f8xuJkFR8i9xAAAAAElFTkSuQmCC",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" mnist_results[mnist_results.measure == \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" kind=\"swarm\", \n",
" col=\"measure\",\n",
" height=8,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "eb2378ce-abfd-42e2-add6-c1e4287ec40b",
"metadata": {},
"source": [
"This produces a pretty dramatic result: UMAP + HDBSCAN take a long time to compute for all 70,000 MNIST digits, while KMeans, as we would expect for large data and a small number of clusters (only ten), takes around two seconds. On the other hand EVoC also comes in at a hair above two seconds to cluster the data. Not quite at KMeans speed, but very close.\n",
"\n",
"How about the clustering quality? This is the kind of high dimensional dataset that KMeans can tend to struggle with."
]
},
{
"cell_type": "code",
"execution_count": 30,
"id": "9168cb00-a657-44d6-a990-22414b2456ce",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-26T17:37:39.604436Z",
"iopub.status.busy": "2026-03-26T17:37:39.604204Z",
"iopub.status.idle": "2026-03-26T17:37:40.255561Z",
"shell.execute_reply": "2026-03-26T17:37:40.254937Z",
"shell.execute_reply.started": "2026-03-26T17:37:39.604404Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 30,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAPeCAYAAAARWnkoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAzbBJREFUeJzs3Qd4VNW6xvE3pNISeu9VepcmIqggKIgFsSFgRT12PYpdjoq9i2Lvig1siGIDBBRp0nsLXVpCDSGZ+3wrd4aZZCYUM6T9f/eZi7Pb7L1nTtb+VvlWhMfj8QgAAAAAAOS4Ijl/SAAAAAAAQNANAAAAAEAY0dINAAAAAECYEHQDAAAAABAmBN0AAAAAAIQJQTcAAAAAAGFC0A0AAAAAQJgQdAMAAAAAECYE3QAAAAAAhAlBN4Cj9uCDD6ply5Yh3+clp5xyim6++WblVYMHD1a/fv0KzOcAQEFAOZd7atWqpeeeey5Hj7l3716dd955io+PV0REhHbu3Kn84LfffstX54vQCLoBaOrUqYqMjNQZZ5xxTHfj9ttv188//5wvA+V33nnHFWjeV8WKFdWnTx8tWLBAeQEFLgD8e5RzEWrUqFGW+/Lpp5+6ss8C3fxSoX0sFf3vvvuuJk+e7H4HGzduVEJCgvKaYPe0U6dOefZ8cXQIuoFc5vF4dPDgwVw9h7feeks33HCDfv/9d61du/ao9y9RooTKli2r/Mpqvq1Q27Bhg7777jvt2bNHZ555pg4cOJDbpwYA+R7lXO4rXry4tmzZomnTpmUp/2vUqKGCbsWKFa7SoWnTpqpUqZKraDhaaWlpSk9P1/EUExNzzOeLvIWgG/mW1QhaoGi1gqVLl3YtlK+99poLmIYMGaKSJUuqbt26+v777wP2W7hwoXr37u0CRdtn4MCB2rp1q2/9+PHjddJJJ6lUqVIukDzrrLPcH2svC8T+85//qHLlyoqLi3O1wyNGjHDrVq9e7f4wzpkzx7e9dQmyZdZi6d9y+cMPP6ht27aKjY11ta/2UPLEE0+oTp06Klq0qFq0aKHPP/887PfR7pfVdF977bXuWq3lN7PHHnvM3Su7p1dccYX279+fba1zsNpa69psXZy9Ro4cqfr167t7aMc+//zz3XLbZuLEiXr++ed9rc92X4/ku7Nrueyyy9x6+36efvrpI7oH9hlWqNk+9p3ccsstWrNmjZYsWeLb5plnnlGzZs3cg0v16tV13XXXaffu3b71dt/sN2PfqxXsdg7Wc8CCef8C+9Zbb/X9tv773/+67/1o5NTnZPd7s3WnnXaaO653P/sd24PZPffcc1TnC+DYUc7lDMo5KSoqShdffLELsr3WrVvnnkls+eGGI1mZbr/H7Mppb/nkb+zYsQEBoz1PnX322a4Mt/KrXbt2+umnn/7V9+s936eeesqV41buXX/99UpNTXXr7bzteWDSpEnuXLzXsWPHDvfMYM+QxYoVU69evbRs2TLfcb3X8+2336px48buec2eDey57+GHH/Y9b9SsWVNfffWV/vnnH3dttsyeF2bMmOE71rZt23TRRRepWrVq7rNs/ccffxxwDcHuabDebl988YWaNGnizsfOJfOzji179NFHdfnll7vnNiu77fkYuYugG/madRcqV66cpk+f7gJwCxz79+/vuuPMmjVLPXv2dIGZjeUxFph07drVBYj2x9AC7M2bN+uCCy4IKJwtYPnrr79cl+kiRYronHPO8dVuvvDCC/r6669doGpB2QcffHDU3bKMBUIWrC9atEjNmzfXvffeq7fffluvvPKK69psgd+ll17q/giHMnToUPfHPbvX4VquR48erYYNG7qXfZ6dg3+AZtf5wAMP6JFHHnH3zAo0C5j/DTvOjTfeqOHDh7t7aN/DySef7NZZgdOxY0ddddVV7vuylwW5R/Ld3XHHHfr11181ZswY/fjjj66wmjlz5lGdmxVsH330kfvv6Oho33L7Hdh3P3/+fPe7++WXX9x36M9+Z1bov//++65wt3tvXe+9rGC0B54333zT9SrYvn27O9ejlROfk93vzQp4u0b735Vds/e3Zg9JVsEC4PihnKOcy6lyzirNrcz3PhNZUGmVq/a3/WiEKqePhFVWW+W5BdqzZ892z2k2pOtYetn5s3tiAb39a/+bsWvzNiJ8+eWX7lztnO1c7b030LXnCXumsx4A9uxj5+YN1o3dK3tWe+ONN1xZWaFCBbf82WefVefOnd01WM84e9a0INzKUXv+rFevnnvvfZ6yxoo2bdq4AN6eI66++mq3z59//nlU99S+a/s9XHjhhZo3b54rk++7774sDSb2HGCNCHZ+1khgz8eLFy/+V/cY/5IHyKe6du3qOemkk3zvDx486ClevLhn4MCBvmUbN260v3ae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aPLkydlu/+GHH6pFixYqVqyYKleurCFDhmjbtm3H7XwBHIHI6MCA21z8qdT0PKlIdEYreJ1u0uBvpbh4bikAAAAKrFydp3v06NEaOHCgC7w7d+6sUaNG6Y033tDChQtVo0aNLNv//vvv6tq1q5599ln16dNH69ev19ChQ1W/fn2NGTPmiD6T+USBXJa6X/KkSTHFc/tMAOQAylUAAPJwS/czzzyjK664QldeeaUaNWqk5557TtWrV9crr7wSdPs//vhDtWrV0o033uhax0866SRdc801mjFjxnE/dwDHyFrACbgBAABQSORa0H3gwAHNnDlTPXr0CFhu76dOnRp0n06dOmndunUaN26crIF+8+bN+vzzz3XmmWeG/JyUlBRXC+//AgAAx4ZyFQCAfBJ0b926VWlpaapYsWLAcnu/adOmkEG3jekeMGCAYmJiVKlSJZUqVUovvvhiyM8ZMWKEEhISfC9rSQcAAMeGchUAgHyWSC0iIiLgvbVgZ17mZWO9rWv5/fff71rJx48fr1WrVrlx3aEMGzZMSUlJvldiYmKOXwMAAIUF5SoAAPlkyrBy5copMjIyS6v2li1bsrR++9euW8K1O+64w71v3ry5ihcvri5duujhhx922cwzi42NdS8AAPDvUa4CAJBPWrqte7hNETZhwoSA5fbeupEHs3fvXhUpEnjKFribXEzCDgAAAABA3utefuutt7opwt566y0tWrRIt9xyi9auXevrLm5d2C677DLf9jZN2Jdffumym69cuVJTpkxx3c1PPPFEValSJRevBAAAAACAPNS93FhCtG3btmn48OHauHGjmjZt6jKT16xZ0623ZRaEew0ePFi7du3SSy+9pNtuu80lUevevbsef/zxXLwKAAAAAACCi/AUsn7ZNmWYZTG3pGrx8fG5fToAAORrlKsAAOTx7OUAAAAAABRUBN0AAAAAAIQJQTcAAAAAAGFC0A0AAAAAQJgQdAMAAAAAECYE3QAAAAAAFMR5ugEUQP8skVb8KhUtJTXqI8UUP7TOZijcOEc6eECq2lqKjM7NMwUAAADCjqAbQM4Zf7f0x8t+7++SLv5Uqn6itHmB9NlgaevSjHUlKkl9X5Qa9OAbAAAAQIFF93IAOWPZT4EBt9m3Q/riyoyW7Q8vOBRwm92bpE8HSskb+AYAAABQYBF0A8gZC74MvnznGumv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5+rPnk/uvPNOd5/i4uJUvnx5V+H97bffhv2zgWMVdcx7AsgxHo9HaWlpiorKnf9JnnHGGXr77beVmpqqyZMn68orr3SF2iuvvJJlW9vGgvPcYp8fihW8CH3fcvN7A4C8LjfL4tWrV6tz586uQvmJJ55Q8+bN3d/tH374Qddff70WL16svH7fSpQo4V7hNnToUE2fPl0vvfSSGjdurG3btmnq1Knu33A5cOCAYmJiwnZ8FHy0dCNfs5pNqxW27rylS5dWxYoV9dprr7mAcciQISpZsqTq1q2r77//PmC/hQsXqnfv3q5wsH0GDhyorVu3+taPHz9eJ510kiv8ypYtq7POOksrVqwI+OP7n//8R5UrV3a1rNatecSIEb6C02qO58yZ49veapFtmdUq+9cuW2Hatm1bV6Ntwa4VXFbYWu1t0aJF1aJFC33++edhv4/2+ZUqVXK16hdffLEuueQSX+uqt7vYW2+95at9t/Ncu3atzj77bHcP4+PjdcEFF2jz5s2+Y3r3GzVqlDtusWLF1L9//4Aa9fT0dA0fPlzVqlVzx7Xt7d57ee/lp59+6r5ru9cffPCB+26TkpJ8LfT2WcG6lx/pOb7//vtu34SEBF144YXatWtXtvdrypQp6tq1q7sm+9317NlTO3bsOOKWavtdWWv94X5L9t/mnHPOccfxvjfffPON2rRp4/ax7+Whhx7SwYMHAz731VdfdddfvHhxPfzww0e037Jly3TyySe79fYwM2HChGzvBQBQFv971113nfu7bcHk+eefrwYNGqhJkya69dZb9ccffxxxS7U9e9gyKz/NmjVrXGu5lVVWFtgxx40b59Z369bNbWPrbB/r+WYO9ywS6hkmc/dyO16/fv301FNPuTLOnqesAsG/8nzjxo0688wz3efUrl1bH3300WGHilk5dvfdd7vnONvWyjR7Fhw0aJBvm5SUFP33v/91zx92fvXr19ebb77pWz9x4kSdeOKJbp2d21133RVQFtpv2spmu//lypXT6aeffkTPj0AoBN3I99599133B9EKKvuje+2117rgrlOnTpo1a5YLiOyP4t69e31/4C1gsoJhxowZLsizQMwCMi8L2u0PrXVXtq5SRYoUcYGPBYnmhRde0Ndff+2CwSVLlrhA0D8gOlJWIFiAtWjRIlerfe+997oWZ2thXrBggW655RZdeuml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AAAAhxC6AdiR7/M7UJmZ4uvDDz+0f5vR6M2ymZ4JAIDSjDIVQE6EbgAAAAAAHELoBgAAAADAIYRuAFZmZqbuvfdeValSRTVq1NBDDz2U9cps27ZNvXr1Unh4uFq0aKFly5b5fNU2b96sbt262f1atmypL7/8MmvboUOHdP3116tatWqKiIhQ48aN9dprr2Vt37Nnj6677jr7+OXLl1eHDh30zTff2G3bt2/XlVdeqZiYGFWoUEEdO3bUZ599lmve0ccff1w333yzKlasqHr16mnmzJm8uwCAIkeZCiA7QjcAa/bs2TbsmqD71FNPafLkyTZcmy8OV199tUJCQrRmzRq99NJLuu+++3y+avfcc4/GjRun9evX2/B9xRVXKCkpyW775z//qY0bN2rRokXatGmTpk+frujoaLvt6NGj6t27t/bt26cFCxboxx9/tD8AmMf2bB8wYIAN2ubYl156qQYOHKi4uDivx3/66adtWDf7jBw5Unfeeaf9IQAAgKJEmQrAiwtAqde7d29Xjx49vF6Hjh07uu677z7XkiVLXCEhIa74+PisbYsWLXKZj48PPvjALu/cudMuP/nkk1n7nDp1ylWnTh3XlClT7PLAgQNdw4cP9/laz5gxw1WxYkVXUlLSWb8XLVq0cL3wwgtZy7Gxsa4bbrghazkzM9NVvXp11/Tp00v9+wsAKDqUqQByoqYbgHX++ed7vRI1a9ZUYmKirZU2TbXr1KmTta1r164+X7Xs60NDQ22ts7m/YWqd33nnHbVt29bWYq9atSprXzPq+QUXXGCblvty7Ngxex/TtL1SpUq2ibmpwc5Z0539OZjR1E0zefMcAAAoSpSpALIjdAOwypQp4/VKmNBqmne7XKYSW7m2nS3Pvv3799fu3bvt1GSmGXnfvn01fvx4u8308T4T02x93rx5euyxx7RixQob0lu3bq20tLSzeg4AABQlylQA2RG6AZyRqV02NcomKHusXr3a576mz7dHenq61q1bp2bNmmWtM4OoDRs2TG+++aaee+65rIHOTI2ACdIHDx70eVwTtM39rrrqKhu2TQ22mRscAIDihDIVKJ0I3QDOqF+/fmratKluvPFGO8CZCcATJ070ue9//vMfffDBB7bp96hRo+yI5WY0cePBBx/URx99pF9//VW//PKLPvnkEzVv3txu++tf/2qD9KBBg7Ry5Urt2LHD1mx7wn2jRo00f/58G8zNOQwZMoQabABAsUOZCpROhG4AZ/6QCA62QTo1NVWdOnXSrbfeapt5+/Lkk09qypQpatOmjQ3nJmR7RigPCwvThAkTbK22mX7MjIZu+nh7ti1dulTVq1e3o5Sb2mxzLLOP8eyzz6py5cp2RHQzarkZvbxdu3a8cwCAYoUyFSidgsxoav4+CQAAAAAASiJqugEAAAAAcAihGwAAAAAAhxC6AQAAAABwCKEbAAAAAACHELoBAAAAAHAIoRsAAAAAAIcQugEAAAAAcAihGwAAAAAAhxC6AXjZtWuXgoKC9MMPPwTMY/Xp00ejR492/HwAAChslKsACN0A/KZu3bpKSEhQq1at7PKXX35pQ/jhw4d5VwAAoFwFSoRQf58AgNIpLS1NYWFhqlGjhr9PBQCAYo9yFQhc1HQDpdDixYvVo0cPVapUSVWrVtXll1+u7du357n/ggUL1LhxY0VEROjCCy/U7Nmzc9VIz5s3Ty1btlTZsmVVv359Pf30017HMOseffRRDRs2TFFRUbrtttu8mtyZv82xjcqVK9v1Zl+PzMxM3XvvvapSpYoN6g899JDX8c3+M2bMsM+lXLlyat68uVavXq1ff/3VNk8vX768unbtesbnCQBAQVCuAjgjF4BS5/3333fNmzfPtXXrVtf69etdAwcOdLVu3dqVkZHh2rlzp8t8NJj1hlkuU6aMa/z48a7Nmze73n77bVft2rXtPocOHbL7rF271hUcHOyaPHmya8uWLa7XXnvNFRERYf/1iI2NdUVGRrqmTp3q2rZtm71lf6z09HR7TmbZHCMhIcF1+PBhe9/evXvb+z700EP2nGfPnu0KCgpyLV26NOv45n7mvObOnWvvP2jQIFf9+vVdF110kWvx4sWujRs3urp06eK67LLLivz1BgCUbJSrAM6E0A3AlZiYaEPrhg0bcoXu++67z9WqVSuvV2nixIleoXvIkCGuiy++2Gufe+65x9WiRQuv0G2CcHY5H+uLL77wOq6HCd09evTwWtexY0d7blkfZpLrgQceyFpevXq1Xffqq69mrTM/GISHh/OOAwAcRbkKIDualwOlkGliPWTIEJ133nmKjIxUgwYN7Pq4uLhc+27ZskUdO3b0WtepUyev5U2bNql79+5e68zytm3blJGRkbWuQ4cOBT7n888/32u5Zs2aSkxMzHOfmJgY+2/r1q291p08eVIpKSkFPg8AAHKiXKVcBc6EgdSAUmjgwIF25PCXX35ZtWrVsv2lzQjiZhCWnEwlsukvnXNdfvcxTL/qgipTpozXsnk8c9557eM5H1/rct4PAIBzQblKuQqcCaEbKGWSkpJszbQZdKxnz5523ddff53n/s2aNdPChQu91q1du9ZruUWLFrmOsWrVKjVp0kQhISFnfW5mNHMje+04AACBjHIVwB+heTlQypiRwc2I5TNnzrQje3/++ecaO3Zsnvvfcccd2rx5s+677z5t3bpV7777rl5//XWvmuNx48bpv//9rx555BG7jxnd/MUXX9T48ePzdW6xsbH2mJ988ol+//13HT169ByfLQAAzqJcBfBHCN1AKRMcHKx33nlH69ats03Kx4wZo6lTp+a5v+nv/f7772v+/Pm2z/T06dM1ceJEu81MD2a0a9fOhnFzXHPMBx98UJMnT/aa8uts1K5dWw8//LDuv/9+2//6rrvuOsdnCwCAsyhXAfyRIDOa2h/uBQDZPPbYY3rppZcUHx/P6wIAwDmiXAVKNvp0A/hD06ZNsyOYm2bpK1eutDXj1EIDAFAwlKtA6ULoBvCHzNRfjz76qA4ePKh69erZPtwTJkzglQMAoAAoV4HSheblAAAAAAA4hIHUAAAAAABwCKEbAAAAAACHELoBAAAAAHAIoRsAAAAAAIcQugEAAAAAcAihGwAAAAAAhxC6AQAAAABwCKEbAAAAAACHELoBAAAAAJAz/h+jFLvO+R7b1QAAAABJRU5ErkJggg==",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(\n",
" mnist_results[mnist_results.measure != \"Elapsed time\"], \n",
" x=\"algorithm\", \n",
" y=\"value\", \n",
" hue=\"algorithm\", \n",
" col=\"measure\", \n",
" kind=\"swarm\", \n",
" col_wrap=2,\n",
" height=5,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "22bc9bf2-9559-455f-b0d7-32446de322e3",
"metadata": {},
"source": [
"Again we have some very striking results. KMeans does as we would expect, getting low ARI and AMI scores in the 0.4 to 0.5 range. However EVoC manages to get almost perfect scores, and consistently. Better still, EVoC consistently manages to cluster more than 90% of the data. The end result is that even the clustering score shows EVoC head and shoulders above the competition.\n",
"\n",
"So, in summary, EVoC is consistently very very fast to compute, coming in as competitive with, and sometimes faster than, KMeans for small to medium sized datasets. EVoC also consistently produces better quality clusters than the much slower to compute UMAP + HDBSCAN option. And it does all of this with essentially no parameter tuning -- we simply have to pick the best option out of a very small number of layers of cluster resolution. Fast, good, and easy -- sometimes you can have all three."
]
},
{
"cell_type": "markdown",
"id": "e573b8a4-b0b5-42e9-bebc-3cf5cef8f646",
"metadata": {},
"source": [
"## Scaling\n",
"\n",
"So far we have looked at small to medium sized datasets of \"real-word\" data because it comes with class labels we can compare clusterings against, and it provides a realistic challenge for the clusterign algorithms to tackle. EVoC performed very well in tersm of run-time, managing to consistently compete with KMeans. But how well do those results generalize to much larger datasets? Now we are less worried about realistic clusterign challenges, we want to see compute time against dataset size, ideally scaling into millions of data samples. To do that we can use sklearn's ``make_blobs`` to generate some easy to cluster high-dimensional data at varying sample sizes. While we are at it, let's add some extra KMeans implementations into the mix. We'll benchmark FAISS's KMeans, as well as sklean's ``MiniBatchKMeans`` which uses a sampling based approach to do a more approximate optimization at much much faster speeds."
]
},
{
"cell_type": "code",
"execution_count": 25,
"id": "b4191c21-48a8-40ad-b547-061ade561ea2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:51:17.870049Z",
"iopub.status.busy": "2026-03-25T20:51:17.869881Z",
"iopub.status.idle": "2026-03-25T20:51:17.970759Z",
"shell.execute_reply": "2026-03-25T20:51:17.970234Z",
"shell.execute_reply.started": "2026-03-25T20:51:17.870034Z"
}
},
"outputs": [],
"source": [
"from sklearn.datasets import make_blobs\n",
"import faiss"
]
},
{
"cell_type": "markdown",
"id": "621629aa-1c48-4e83-b2f9-abf767676423",
"metadata": {},
"source": [
"We need similar function wrappers for ``MiniBatchKMeans`` and FAISS: "
]
},
{
"cell_type": "code",
"execution_count": 26,
"id": "5075cd5e-fc1a-4b26-b8dd-895aa2156ba2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:51:17.971578Z",
"iopub.status.busy": "2026-03-25T20:51:17.971423Z",
"iopub.status.idle": "2026-03-25T20:51:17.974750Z",
"shell.execute_reply": "2026-03-25T20:51:17.974323Z",
"shell.execute_reply.started": "2026-03-25T20:51:17.971564Z"
}
},
"outputs": [],
"source": [
"def minibatch_kmeans(data, n_clusters=10):\n",
" return sklearn.cluster.MiniBatchKMeans(\n",
" n_clusters=n_clusters, \n",
" n_init=\"auto\", \n",
" batch_size=4*n_clusters\n",
" ).fit_predict(\n",
" data\n",
" )\n",
"\n",
"def faiss_kmeans(data, n_clusters=10):\n",
" kmeans = faiss.Kmeans(data.shape[1], n_clusters, niter=50, nredo=1, gpu=False)\n",
" X = np.ascontiguousarray(data, dtype=np.float32)\n",
" kmeans.train(X)\n",
" _, labels = kmeans.index.search(X, 1)\n",
" return labels.ravel()"
]
},
{
"cell_type": "markdown",
"id": "0ce50a1f-73cc-4c26-9f05-fe807aafa04b",
"metadata": {},
"source": [
"Now let's just set up a loop and run everything! We'll scale the dataset sizes from 10,000 up to over 3,000,000, have 4 runs of each implementation at each size, and colelct all the results. If you are running this yourself be warned that (since we are clustering millions of samples many times over) this can take a very long time to complete."
]
},
{
"cell_type": "code",
"execution_count": 27,
"id": "8d2312a4-6f73-4d8d-b260-0a4529a67408",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-25T20:51:17.975285Z",
"iopub.status.busy": "2026-03-25T20:51:17.975154Z",
"iopub.status.idle": "2026-03-26T09:38:20.359235Z",
"shell.execute_reply": "2026-03-26T09:38:20.357749Z",
"shell.execute_reply.started": "2026-03-25T20:51:17.975274Z"
}
},
"outputs": [],
"source": [
"algorithms = [\n",
" (\"UMAP + HDBSCAN\", lambda X: umap_hdbscan(X)),\n",
" (\"Sklearn KMeans\", lambda X: kmeans(X, n_clusters=n_clusters, kmeans_algorithm=\"elkan\")),\n",
" (\"FAISS KMeans\", lambda X: faiss_kmeans(X, n_clusters=n_clusters)),\n",
" (\"Sklearn Minibatch KMeans\",lambda X: minibatch_kmeans(X, n_clusters=n_clusters)),\n",
" (\"EVoC\", lambda X: EVoC(X)),\n",
"]\n",
"\n",
"scaling_results = {\"size\": [], \"time\": [], \"algorithm\": [], \"ari\": []}\n",
"n_runs = 4\n",
"\n",
"for size in np.logspace(4, 6.5, num=8):\n",
" for n in range(n_runs):\n",
" n_clusters = int(2 * np.sqrt(size))\n",
" blobs, labels = make_blobs(n_samples=int(size), n_features=1024, centers=n_clusters, cluster_std=3.0)\n",
" \n",
" for name, fn in algorithms:\n",
" start_time = time.time()\n",
" clusters = fn(blobs)\n",
" scaling_results[\"size\"].append(size)\n",
" scaling_results[\"algorithm\"].append(name)\n",
" scaling_results[\"time\"].append(time.time() - start_time)\n",
" scaling_results[\"ari\"].append(sklearn.metrics.adjusted_rand_score(labels, clusters))\n",
"\n",
"scaling_df = pd.DataFrame(scaling_results)"
]
},
{
"cell_type": "markdown",
"id": "956dc26c-ab67-454e-b9f6-77ea85341ff4",
"metadata": {},
"source": [
"Now let's look at the timing results -- how does the runtime scale with increasing dataset size for these different algorithms?"
]
},
{
"cell_type": "code",
"execution_count": 28,
"id": "e4ffa7e1-889a-494d-8548-a48500176dda",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-26T09:38:20.360185Z",
"iopub.status.busy": "2026-03-26T09:38:20.359995Z",
"iopub.status.idle": "2026-03-26T09:38:21.088775Z",
"shell.execute_reply": "2026-03-26T09:38:21.088224Z",
"shell.execute_reply.started": "2026-03-26T09:38:20.360170Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 28,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "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05DiXNlsGRx2BVozf0j9kqel126NrYuGRGZ2FQ1N3TWZwKNkBGFXhmoJtOjfX/5b/1j6D2tW17ZGF4+25nMdOvxQq13RVHOZqq6MDr6BTowcOVIbN25UWVmZtX7ttdd05JFHqq6uTgMGDOA1ywIEXgAAABkkHG7TpkavVeG1w3Pb2vSrF77Ue19tjh47afpQnXXQ6IT3s9tyVFGcK4fdltlD6ps2ReZ3Zapl86RXbo6tC8qlU34vFQ9L3TURdmWsza2b9fclf7fCrkb/9lWPuw/e3Qq69hy8ZyTochcrz5GXkmsFepvf75fL5dKQIUN4sbNYBv8UAwAA0P9m7Ziwyx/ccdhlPPTGV3phSWV0ffD4Ul10zISElRq2nEjYZdoZM5YJuZo2ZvaOjMufk16+0XwykXV+WaSyy8zuShXaGDPS1w1fa847c3Tc48dZA+m3DrvMnK3DRhyme464R7cecqv18YiiEaooqCDsQq9ZsGCBDjnkEKuKqrS0VDNnztSqVavinj937lxNmDBBeXl5VgXW3/72N+vvsfr6+ug5jz/+uKZOnSq3263Ro0fr17/+dcfKxdGjdcstt+iss85SSUmJzj77bKul0TzOZ599Zn1sHtsYOHCgddyc2y4cDuvyyy/XoEGDrJBszpw5HR7fnP/ggw9an0t+fr4mT56sd999VytXrrRaIgsKCnTggQcm/DyRfBn8kwwAAED/CrsqG33WPK6uePTDdXrso/XR9e4jSnTdSVOs6q14zA/s5cVu5TozfEZPpg+pX/mi9NINUtuWYDNvkHTq76UBo1J3TTZbZGaXI/GgcqSPhdULdeErF2r2U7P1+IrH5QvF/ky47W7NHDtTDx7zoK7c70rtO2RfjSgcYc3tcjKXDb2sublZl1xyiT788EO9/PLLstlsOvXUU61QaVsmiPrWt76lU045xQqmzjnnHF1zzTUdzvn444/1ne98R9/73ve0cOFCK4y67rrr9Ne//rXDeb/85S81bdo063xz+7btjSY0M5YvX261Ot53333R203IZkKr999/X3fddZduuukmvfjiix0e4+abb9YPfvAD6zonTZqkM844w7req666Sh999JF1znnnnZeEVxBdRUsjAABABjC7K7b4g106d8GiTVZ1V7txgwt08ynTdli1VVboUr4rw388zPQh9V+9Kr1wjdS2JdjMHRBpYxw4JsVh1wjCrgwJxt9Y/4b+sugv+rjq4+1uN/O4Zo2dpRPHnmiFW8znQiqcfvrpHdZ/+tOfVF5eriVLlqiwsLDDbb///e81ceJEK6wyzMeLFi3SrbfGZhvefffdOvroo6Mh1q677mo9lrnP1lVaRx11lC677LIOYVo7u91uVW8Z5lq2neG122676YYbbrA+NtVm999/vxXWHXvssdFzfvSjH1nBm3HFFVdYFV3mmo4//njr2IUXXmidg76T4T/RAAAAZL8aj08eb9fCrndW1ehXL8R2XBtakqs7T99Nhe7EP/YNKnCpKDfDd9zL9CH1q9+QFlwZa8V0l0R2Yywdl7prorIrIwTDQc37ap614+LK+pXb3T60YKhOGX+Kjh51tAa4BzCfCyll2vpMEPTee++ppqYmWtm1du1aTZkypcO5ptpq33337XBsv/3267BeunSpTj755A7HDj74YGtXxVAoZIVZxj777LPT12wCr60NHTpUVVVVcc+pqKiw3k+fPr3DMa/Xq8bGRhUXF+/0taDrCLwAAADSWH2LX42tgS6du3B9g26at1ThLWOfBuY7dde3drPCrERK8pwakJ/hrWrWkPqNmTukfs1b0nOXS+Etwaa7SDr5t1LZrqm7JjPrzWpjdKfuGrDDHRf/tfxfemTZI9rUvGm728cPGK9vTfiWDhx+oFXNVeIqoWURKTdr1iyrhfAPf/iDhg0bZgVeptXQDJLvrGpx27mT5lh3zzFMS+LOcjo7/oOQeb5tWzC3Pqf9ejo71lnrJnoHgRcAAECaavQGtLl5+18AOvNVtUfXPLUoOtC+wGW3KruGD0i8y5qp/CotzPBAIzqkPkN/ifj6HWn+ZVJ4S7DpKpBm3y+VT07dNRF2ZcSOi//58j9q8Ddsd/te5Xvp9Amna4/Be6jIXWSFXXaz6QCQYrW1tVZFlhnwfuihh1rH3nrrrbjnm1lY8+fP73CsfR5WO1MVtu1jvPPOO1ZrY3t1V1eYXRsNUxWG7EDgBQAAkIY8vqBqmro2eH1Tg1dXPL7Quo/htOfollOmaXx5x1ko28pz2TW4KMPDLsNTlblD6te+J82/NBZ2OU3Y9VupYlrqwy5nbuquAZ1a17hOf170Z6t90RvydrjN2nFx+GE6bcJpmjBwgtW2WOQsSrgrK9DXzA6IZmfGhx56yGoLNG2MV155ZdzzzdB3M6PLzMT6yU9+Yg2Ebx9G3/69femll1ptj2Zo/He/+11rd0QzY+t3v/tdt65tl112sR5z3rx5OvHEE61dIbedKYbMwi6NAAAAaabVH1J1F8Ouuha/Ln/8C9VuqQQzmzBee9IU7T6y48DdbZkB9hVFuZn/y3BrneRrUkZa94H07CVSaEsVnzNfmv2/0pDYzJfUhF3DCLvSzJLaJbrktUs066lZ+s+K/3QIu8yOi2YQ/UPHPKSr9r9K+w3dTyOKRlhVXRn/5xtZx+zI+Oijj1o7JZo2xosvvjg6kL4zY8aM0X/+8x898cQT1oysBx54ILpLo9sd+QebvfbaS4899pj1uOYxr7/+emsXxa0H1nfF8OHDdeONN1oBnJm3xY6KmS+nrbPmVmzHDJYrKSlRQ0MDA+YAAECv8QZCVsVWuAs/ojX7grr4sc+1ssoTPXbpsbvqpN2GJryf027TsAF5spt0LJP5W6TGDcpI6z+S5l0Qq0xz5kmz/lcatmcahF2J22DRd9755h39ZfFf9N7G97a7rchVZAVdJ409SeX55SpxlyjPwdcO2c/s0Gh2b1y3bl2qLwVpjpZGAACANGHmb1U2di3sMude9/SiDmHX/xwyZodhlwm5KopzMz/sCgUkz/ZDujPCN59I8y6MhV2OXGnmbwi7YAmFQ3p+zfP625K/WZVd2yrPK9epE07VMaOOUWleqdW6aKq8gGxlWhNNy6JphXz77betijCqr9AVBF4AAABpIBgKW5VdofYtFhMw59zy7FJ9ti42rPpbew/Xf+03MuH9bDmRsMu0M2Y0M5zeVHZl4pD6DZ9uqeza0pJmdkCceZ80fK/UXROVXWnBG/TqyRVP6v+W/p/WNq3d7vbRxaOtQfSHjjhUA9wDrKDLaeu4cxyQjVasWKFbbrlFmzdv1qhRo6yZXVdddVWqLwsZgJbGLqKlEQAA9BYTYG2ob1UgtOMAx0yj+PULX2r+olh107FTKnTFjIlWoBWPmeVTUexWvisL/r2zcaPkb1bG2fiFNPdcKdASWZuqnJn3SiP3S901me+ZoqGSKz9119DPNfoa9ciyR/TY8sdU3Vq93e3TSqfp9F1P134V+0UG0buK2HERALogC37iAQAAyFzhcJs2NXq7FHYZf3xrdYew64Cxg/SL43ZNGHYZZYWu7Ai7WjZncNh13lZhl0s66deEXf3YJs8m/X3J3/XUyqfUFOi48UKOcnTA0AOsiq6pZVPZcREAdkIW/NQDAACQmUy1VmWTV75AqEvnP/bROv3zg9iQ3unDi3XDzCly2BO3KJYWuFWUmwWtTz5PJPDKNJsWbgm7tgR1pg3txF9Low5M3TVR2ZUyq+pX6W+L/6b5q+fLF+q4G6sjx6EjRx5pzegaP2C8NYg+3+zeCQDoNgIvAACAFKlu8qnV37Ww6/nFm/T717+KrscOLtCtp0yX22lPeL+SPKdK8rMg7DID3j2Vysyw69yOYZep7NrloNRdE2FXnwu3hfVF9RdW0PXqulcVauv4597srnj86ON18riTNbJopFXRxY6LANAzBF4AAAApCrs8vmCXzn1nVY1++fzy6HpoSa7uPG26CnMT/yhnbi8tzILd28IhqWmjKYlTRoZd/q0ru34l7XJw6q6JsKtPBcNBvbfhPWvHxfc2vrfd7SWuEs0aN0snjTlJFQUVVkWXy7S7AgB6jMALAACgj21u9qvJG+jSuZ+vr9dN85aqffPGgflO3fWt3XYYZJl5XYOzIewyIVfTJinUtXAwbVQu6iTs+qU0+pDUXRO7MfYZf9Cvl9a+pIeXPqxFNYu2u708r1ynTThNx+xyjMryythxEQB6AYEXAABAH2poDai+xd+lc1dUNunaJxfJH4wMtC9w2XXn6btp+IC8hPczbY7lRW5rZ8aM11wjBVqVUSoXS09vHXY5pBPukkYfmuKwa7jkzE3dNfQDHr9H876ap38u+6e+aoi1ILfbpXgXfWvCt3T4iMM1IHeAil3F7LgIAL2EwAsAAKCPmKquWk/HIdXxrNvcoiseX6jmLTO+XA6bbj11msaXFya8n9Nu05DiXNlsWRB2eRsib5mkcon09M8lv2ersOuX0pjDUndNNlsk7HJkQcVfmqrz1unxLx/XY18+po3NG7e7ffKgyfr2rt/W/kP3t9oWi1xFsuUk3mwCANAz/F8WAACgD7T4g6rx+Ls83+sX//lC9a2RtkeTXZndGHcbMSDh/Rw2m4aU5MqeDWGXqeoy1V2ZpGqpNHfbsOuuNAi7RhB29dIuqybcuvfje3XK06fovk/v2y7s2qdiH91x6B2658h7dMKYE6yB9CbwIuzqf8LhNi1c36DXv6y23pt1bzriiCN00UUXbXf8qaee6lD9+9e//tVaT548ebtzH3vsMeu20aNHb3dba2urBg4cqEGDBlkfb8vcx9zXvOXn52vatGl68MEH1ZvMc957773bHZ8zZ4722GOPDuv2a3M4HCorK9Nhhx1m3dfn8233Orafa7PZVFFRoW9/+9v6+uuvo+eEQiHdfvvtmjRpkvLy8qzX5IADDtBf/vKXDo+1adMmnX/++Ro7dqzcbrdGjhypWbNm6eWXX97umm+77TbZ7Xbdcccd293W/jWbMWNGh+P19fXW8ddee62br1z2osILAACgl3kDIVU2+qxfkHekoSWgy//zhaqaYj90X3nCJB04rjTh/Ww5OaoocVsVXhnPzOvKtCH1prLLhF2+pljYNeNOaczhqbsmm31LZRdD0JMpFA5pXdM6PbL0ET3z1TPyBDwdX3bZdOiIQ3X6hNM1adAkK+DKd+Yn9RqQWd5ZWaMHXl+lVVUeBUJtctpzNK68UD87fJwOGl+W6stTQUGBqqqq9O677+rAAw+MHv/zn/+sUaNGdXqfxx9/3AqxzN9rTzzxhL7//e9vd85NN92ks88+Wx6Pxwpp/t//+38aMGCAvvvd7+7wmtasWaMxY8Z06e/NnTF16lS99NJLCofDqq2ttUKiW265RQ8//LD1cVFRUfRc8zmYz8Vciwm6TJD43//933rzzTejAdpDDz2k+++/X/vss48aGxv10Ucfqa6ursPnc/DBB1uf/1133aXddttNgUBAzz//vM4991wtW7asw/WZsOzyyy+3vgZXXnnldtdvgjoTlL366qs68sgje+U16reB16pVq6wvgHl/3333qby8XAsWLLASSvONAwAAgFjYtanB26Uf2k0V2JVPLtTXm1uix847cryOmVyR8H7mX3RNZZfbYc+SIfUbTDmEMjfsskfCrrFHpO6a7I5I2GV3pu4askwgFNCK+hX6x9J/6Pk1z8sX6lgJ4rQ5dcyoY6xh9GNKxlhBV66DmWn9nQm7rn5yobUr78B8l1x2m/yhsJZubLKO33bq9JSHXiY8OeOMM6xwpT3wWr9+vRX8XHzxxfrnP/+53X3+9Kc/WaGP+bvNfNxZ4GVCoyFDhlgfmzDJVIyZCrOuBF598Tm3X9uwYcM0ffp0HXvssdp999115513WtfbzlSotZ87dOhQK6Ay4V27Z555Rj//+c+tyq925nG2Zm43f1d/8MEHVsDYzuQnP/7xjzuc+/rrr1tVcyZk+/vf/6433njDqkDbmnmM73znO1YY9v777yftdck23f4nQPPim28G86KaJNektcYXX3yhG264oVuPZb5wpoTPfIOZL7755t/aWWedFS0fbH8zpYFbMyWHpizQlCGaL/rs2bOtP5xbM8nqmWeeqZKSEuvNfGzK/QAAAHqTLxgJu8JdCLvMYPrrn16s5Zu2hCaSfnDgLjptr+E7vK8ZUJ/rzIKwy/BUSsGutX6mz4D6n3UMu46/I8VhlzPSxkjYlRTeoFcfV36sy9+4XN9/9vuau2puh7Arz5FnVXP9+fg/6/L9LtdeFXupoqCCsAtW26Kp7DJhl5mtaP4/beYrmvdDit3y+ELW7b3d3tgVP/nJT/Svf/1LLS2Rf3AxFVmmZc608G3LFL6YajATuJi3d955R199tf0mDdvKzc21qprSlWlJPOGEE6ycI57Nmzfr3//+t/bff//oMROGvfLKK6quro57H1MgZIKyrcOudqbqa2smQPyv//ovOZ1O671Zd8ZUli1cuFD/+c9/uvFZ9i/dDrxMgmjSzhdffFEuV6w82pTRmW/67mhubraST1P6F4/5Q7Zx48bo2/z58zvcbsoJn3zyST366KN66623rABu5syZVh9tO5NWf/bZZ9Y3mXkzH5vQCwAAoLeYAKurYVco3KZb5y/VJ2tj/yB3yh7D9MMDd9nhfcuK3CpwZ8mUipbNkq9je1haq1y0zYD6LZVd445KcdhlKruy5HsihVoCLXpj3Ru66NWL9KMFP9JLa19SsC0Yvb3EVaIzJ5+pvxz/F52/5/maWjZVZXllctlpIUXE4g2NVhujqezadtdcsx6Q77RuN+elmplxNW7cOCs8MVVbJvDatvKonakEM8FQ+wwv8zu7ORZPMBi0Hs+EM0cffXQvfhbSFVdcocLCwg5vZh5Wd0Iv0364td/97nfW45iwqrS0VMuXL+/w+d59991W2GWCL9OqaKq/nnvuuejtK1eutF5T89g7YtohTbuoqZ4zzHvzNTHHt2UKhy688EJdc8011muMJARe5pv01FNP3e744MGDrd7X7jB/SEx4dtppp8U9xwxzM9847W/mD1S7hoYGK+389a9/rWOOOUZ77rmn/u///s+6RtOPayxdutQKuf74xz9a5Znm7Q9/+IPmzZtnfaMCAAAkWyAUCbtMkLUj5ofge178Um+uiA1oP2Zyuc47avx2vyBta1CBS8W5WdKyZoIuE3hlVNh1bscB9TPuksamcJaKmdVVYiq7CLt2lvnz2OBr0Pyv5utnL/1M575yrt7e8LbaFPuzPDhvsM7Z7Rwr6Prpbj+15nSV5pVaLY3A1ja3+K2ZXaaNsTNuu02BcJt1XjowAZcZXWS6ukwhyYknnrjdOaaw5G9/+1s0kDHMx+bY1kUnW4dPZpC7qW76xS9+oXPOOSfu85v2vvaQqn1U0tbBVVfGJ5nnMAUuW79t3X7Ylf8HbPt3r2nXNI/z+eefW0U248eP13HHHaempkhl75QpU7Ro0SK99957+tGPfqTKykqrk+1//ud/oo9p7OjvdOORRx6xhtq3t0SaINKsTYFPZ8xrbMK2RIFjf9btvw1NuZ2ptDID5Lb26aefavjwHZfcd5fpGzYzwszzHn744br11luttfHxxx9bJZHmm23rlNMMzzNllccff7xVdWbaGLcuOTRtkeaYOWfixImdPq9pldx6h4bOElUAAIBtBUNhbaz3KtiFGVTmh+AH3/hK8xdtiv2cMnaQLj9+ojWEPpGSPKcG5GdJJUnQF2llzLjKruaOA+pT2cbozJWKhkV2ZcRODaI3Qder617Vv5b/S0s3L93unBGFI/StXb+lo0YdpUG5g1TsKu7SL7Dovwblu6wB9WZmV66pAN2GLxSW05ZjnZdsxcXFVoHItsxoH3NbZ0ywYwalm1a5H/zgB9acq22ZIevffPPNdnO4TNj1wgsvWEUtW4dPZkyRmYFlZl/t6M+L6eZqb3k0z2F2SDRBUzvT4rcjZtSRCaS2tnXRzI6Ygpltsw6THbQ/pnlvim7M52NaQNtDLbOD47777mu9mblnphDHdJWZ6qsJEyZYn7t57FNOOSXh85vgavHixR1eezNY3zznT3/60+3ONznJVVddpRtvvNHqdEMPAy/THmhSRNO3ar5o5sV/++23ddlll1l/KJLJ/GExg9922WUXrV69Wtddd52OOuooK+gylV9mW0/TVmlKKbdm+ozNbYZ53x6Qbc0caz+nM2ZbUfNNAwAA0K2wq6FrYZfxyAdr9dhHsdmj04cX6/qZU+TYwU6LhW6HSgvd2fGFCYekxg2ZsyPjpoXS3HM7hl0n3JXa3RideVLRUMKunRxEv9m7Wc9//bz+vfzfWtPYsZXJmDBggr6967d1yIhDNMA9QIXOQoIudMnUYcXWboxmQP2QYluH7xvzDx71LQFNHlpknZdspn1u67a6dh9++GHcog8TDJmZ2Ga4/O9///tOzzHBy/e+9z0ryNnaHXfcYd22deDVWfiUiPm9v1174NOd+/eU2SnRdIeZACkRuz0SXprB8vGYqq/2MU4mQDPFOL/97W91wQUXbDfHy4SQJrgynWpmd0dT9LN1SGduN0PrTRWZKe7Zlplp/pvf/MbaUBA9DLxMhZVJaU01l/lDar6QJs01Qdi1116rZNo6NTZfWLPFp/lD8OyzzyZsg9y2DLGzJLmzUsWtmW/ySy65pEOFl9mFEgAAoDOmfdGEXaadsSue/myD/vRW7Jfr8YMLdesp03c4fD7f5dDgoiwJu0zIZcIuE3plbNj1S2lMx92z+pQrPxJ2UWnU7UH01S3Vmr96vh5f8bg2Nm/c7pzdynazgq59h+yrAbkDVODcftg0kIgZUP+zw8dZuzFuavRZM7tMG6Op7DJhV6Hbbt1uzks2syugmZVtWglNZZBpKzRzuE0o9fDDD8e9n5m1ZWZWmVlV2zKtc2ZHwrlz524XvPzwhz/USSedZJ1jxh2lOzPzyhTAmAIeM5rJhExm3JJpITSVaVszg/zbi2VMu6I5zwzgb+80+9a3vqWDDz5YBx10kDWGyRTrmDxh1113jc7tMq+puX2//fazdl80s77MNZivyQMPPGBVf5mvjbl92x0ZDTOaydx+zz33bHebuRZTrGO+1uhh4GXKCP/xj39YXyTTxmi+QczsLFOm19tM2aAJvFasWGGtzTeT3++3dmHcusqrqqrK+mZqP8d8U27L/EHsbMeJdqaCzLwBAADsiNlha2NDa5fDrpeXVuk3L0d+njFGDMzTnd+arsLcxD+auZ12a0fGrGmj8lRF2hkzwcYvpLnnSYE0CrvchVJhBWFXNzQHmlXZXKlnVj2jJ1c+qVrv9jOI9x+yv9W6uEf5HlbbYr4zP5lfNfQzB40v022nTrd2YzQD6hvCbVYbo6nsMmGXub03jB49Wm+++aZViWWCGa/XawUwJtAyXVTxmGDMvHXm73//u1Wd1NngebOJXVFRkRWmbV04kq5M26DJF0y1lmlZNIU8JqT62c9+tl0OYGaAmzfD5A4mrDLtl+2VcqZ665///KfVJWbaSE0GYTrTTGtoe6WaqfL65JNPrAKiSy+91BoTZYLBvffe2wq8TK5h2iBNN11nTj/9dOvx77zzzk5vN4GjmW2+ZMmSJL9SmS2nrX2CWoqZH9zMbouJelpN8moqyx566CGrfdJ8M5lvEvONYbZDNcw3zogRI6xvQPONZ5JS8837/vvvW2mpYT42c7xMyWK8cs5tmQov8wfBPGe8nmcAAND/mB+lTGWXN9C1KqX3vqrVdU8vjg60H1zo1m/+aw9VFOcmvJ/TbtOwAXmy90IlQEq01knN3dvwKGU2fi7NPT/Nwq4iqSj+P96i45/RpkCTNjRt0NOrnrbemvyRYdPtbLLpsBGHWUGXGUJf4i5RriPxn0mgu/8wYnZjNAPqzcwu08bYG5VdAHpQ4WX+wjDbYr766qtWJZWp8NraE0880eXHMjs/mC0625nSPzOUzvSrmjeTiJok0ySvZmvQq6++2uoDbt8l0gRQP/nJT6yE1JRcmvuYWWLTp0+3dm00Jk+ebG2TevbZZ+vBBx+0jpmSTjPQrathFwAAQDxVTb4uh12fr6/XnGeWRMMuM3j+l9/abYdhl8Nm09CS3OwJu0xLYKaEXRs+lZ65QAq0RNZmJ74TfymNPjR115RbIhWmf8tQOgyib/Q3an3Tej2x4gk9u/pZtQY7ztxx2Bw6ZtQxOm3CaRo/YLwVdLnsWbIZBNKKCbemjyhJ9WUA/Uq3A68LL7zQqrAyJYumJbAnJfVmIJt5nHbtpY+mHM+U9ZmhbaZs0gxpM6GXOdfshGBKJduZHlZTJmgqvMzQOFNeaco02wfJGaYF0wyHa++xNYP4TD8zAABAT1Q3+dTsC3bp3C8rm3Ttk4vkD0b+sTDfZdedp0/XqNLE7VIm5BpSkrvDQfYZI+iXmuJvHJRWvvlEmmfCri0hiQlCTvyVtMvBqbumvAFSQe+0QGXTIPoGf4PWNKyx5nO9sOYF+cP+Dufk2nM1Y/QMnTrhVI0qHqUSV4mc9h3vAAcAyOKWRlNFZVoITzzxRPUntDQCAICt1TX7VdfS8ZfoeNbWtujCf32mhtbIdusuh013njZdu48ckPB+tpxI2LWjQfYZwwynb1gnhboWEqbU+o+keRdKQW9kbXdvCbsic2JTIm+gVLD9IGnEBtGbiq6VdSv1ny//o1fXvapgW8fvNTN4ftbYWZo9braGFQ5TsbtYTlO1BwDIOt2u8DJthGPHju2dqwEAAMgAjd5Al8OuykavLn/8i2jYZSq2bpg5ZYdhl6miN62OWRN2mX9jbdqYIWHXB9K8i2ID9U3YddLd0qgDUndN+YMib9hOS6DFCrqW1S7Tv7/8t9765i2F1XHsygD3AJ0y/hSdNOYklReUW8PoTTsjACB7dbvC629/+5sWLFigP//5z3F3b8hGVHgBAADDtDCaEKsrNjf7ddG/PtP6ukhLnBkEcfWJk3T05B0PGy8vzlWhO4t+IW+qlHwdB4WnpXXvS/MulkJbwi7H/2fvPuCbLNc2gF/NbpOme7H3Hm5RVEBkiIC4917nO0c9juPee289HvfeAwUBBRXFgagoe+9RutukaZvd73c/oUnTFhKgTdtw/b9fv5In75u8SfHQXr3v+5Gw62mgc2Dzo1bBsKsR+RHG4XGooGt5yXJ8vPpjLChY0Oi4rMQsnNL7FIzrNg6ZiZlINiRDq4mTEJmIiHZrj7+Lki1MZcvN7OxstdWpXh9eAixbbRIRERHFIxlOL0Pqo1Hp9OCmz5YEwy5x1bG9ogq7MpON8RV2VZe1j7Bry3xgxvX1wi4TMPFpoNOhrXdN0sIorYyk+Gv9aodFm9OGxcWL8fGaj7GoeFGjd6ejpSNO7X0qRncdjXRTugq6NAlxMgePiIiissffSV144YVYuHAhzj333H0eWk9ERETUXri8PhTYnKqyJJIajw+3fL4M64urgmuXHNUNUw7sGPHctCQDrKY4minktAcCr7Zu8y/AzP8Avp2tqvpEYOIzQMeDW++aZDi9DKkneP1eVc1V6arEHwV/qKBrZdnKRu9Md2t3nNb3NBzT6RgVdFn0Fv68QkS0n9rjwGvGjBn45ptvcNRRR7XMFRERERG1MR6fH4U2F/xRhF2yC+OdXy7Hih324NoZh3TC2Yd1iXiuNVGPNLMBcUN2N6wqRpu3cR4w60bAH5izBn0SMOlZoMOBrXdNlizAlIL9Xd2Oi1LVNT9/Pj5a/RE22DY0Oq5vWl+c3vd0DO8wHKmmVDWcnoiI9m97HHh17twZVqu1Za6GiIiIqI3x+WtVZZfX74/q2AdmrsTCzeXBtYlD8nD5MT0iVplIC2OmxYi44fMEhtTv2bjY2Fv/PfDNzYEdJIUEJZOfBfIOaL1rYtgFl88Fm8umKrp+2v6TqujaWrm10Vs1JHOICroOzTlUBV1JElYSERHtTeD1xBNP4MYbb8T//vc/NcOLiIiIKF75JeyyO1WFV8Rja2vx+OzV+GltSXBtVN8s/Ht074hhV5JBh6zkOAq7JDyy58sbiDZt3Rzgm9uA2p1hl8ECTH4eyB3cetdkyQZM++8vl+t2XJSKru+3fI9P13yKguqCRsdJwCVB1wHZByDFmIJE3f6zmRZRc5F/m6ZOnYopU6Y0eb/8vH/NNdeoD6L2aI8nN8rsrrlz56Jnz55ITk5Genp62AcRERFRPJBZXYWVTrg8vqiO/e/c9fhmeWFwbViPdNxyfD9oNbsPu0x6LXKsxviZMyQVXVLZJRVebdmar8PDLqMVOPHF1gu75OufnLPfhl1VnirkO/JVFdcnqz/B5XMux/OLng8LuxKQoFoWnxn5DB4+5mGM6jIKueZchl3UPsgvAPL/BtZ9G/jcwr8QKCoqwhVXXIEuXbrAaDQiNzcX48aNw/z58xEPNm3apP7dXLQotGlFZWUlRo4ciX79+mHr1kBFqBwjH7/99lvY+S6XCxkZGeq+H374IebXT220wuvpp59umSshIiIiakOKK12ocUcOu8Rb8zfj87+3B28P7ZSCuyYOgE67+98tGnQa5FpN8RN2CUcR4HGiTVs1A/jubqB25w+cMivrxP8CWf1a53rk62/JAYwW7E8kKHZ4HIHWRXclZm2chanrpqLCVRF2nOyuOLLTSJzW5zT0y+iHFEMK9No42tiB4t+GH4GfnwJK1gZmBWr0QGZv4KhrgR4jWuQpTznlFHg8Hrz11lvo0aMHCgsL8d1336GsrG1tIuJ2u2Ew7PvsyuLiYhx//PHqzz///DMyMzPDxjK98cYbGDZsWHBNKtssFkubez+olSu8Lrjggt1+EBEREbV3JQ4XHC5vVMd+snAb3p6/OXi7b04y7p8yCEa9drfn6bWBsEsToQKsXZHdGF2VaNNWfAl8e1e9sCsVmPJS64Zdybn7Vdjlr/WrkGubYxs22zbjvZXv4ZJvLsEby98IC7t0Gh3GdxuPl8e8jDuPuBPDOgxDZmImwy5qf2HXV9cAhcsBgzkQbstnuS3rcn8zq6ioUKHPI488glGjRqFr16447LDDcMstt+CEE07Y5Xn33nsvcnJywqqm6rPZbLj88suRnZ2t5nofe+yxWLx4cfD+9evX48QTT1SPIWHSoYceim+//bZRm+T999+PCy+8ECkpKbjsssvw5ptvIjU1VW2O179/f3Xu+PHjsWPHjqher1RzHX300aoDTbrR6oddQnKKDz/8EDU1NcG1119/vcn8Yvv27TjjjDOQlpamKsDk9Ug1WZ0//vgDY8aMUc8h1z9ixAj89ddfYY8hv8R69dVXcdJJJyEpKQm9e/fGtGnTgveXl5fjnHPOQVZWFhITE9X9EshRKwVedrs97M+7+yAiIiJqzyqq3bDXRNeO99WSHXjxh/XB210zkvDwyYNhNu6+iF6n0SA3xRSxAqxdcdoCgVdbtuxz4Pt7pbYocDspAzjp5UClRauFXXmBH373Az6/D+XOcmyr3IZNtk14a9lbuGT2JXh35buo9ISCUoPGgMk9JuO1sa/h1sNvxSE5hyAjMUMFYETtirQtSmWXyxH4b12fCCRoAp/ltqzL/c3c3iiBkXx88cUXqnUvmmrLf//733jttddUUHbAAQc0eYyEZQUFBZg5cyYWLlyIgw46CKNHjw5WSTkcDkyYMEGFXH///bdqoZw0aRK2bNkS9liPPfYYBg0apB7jjjvuUGvV1dV4/PHH8c4772DevHnqnP/85z8Rr3316tUYPny4amP8+uuvVejV0MEHH4zu3bvjs88+CwZk8hznnXde2HFyDRIQynsn98t7URe+SSVaXdukBGU//fSTapOUsEpes6zXd8899+D000/HkiVL1P0ScNW9T/KaV6xYgVmzZmHlypV48cUXG4V01DwSauVvbgRarValq5LkajSaJsvu5WFk3eeLrvS/vZEwTxJcSbW5SyUREVF8sjs9KKmM/MOB+H5VER6YsbIuOkFeiglPn3FAxOHzmoQE5KWaYNTtvgKsXZGqrsrQ/LI2acnHwLxHQreTMoGT/gekdW+d65Hvp60dAj/4xjmP36MqumROV1lNmWpblPZFpy+89VUGz0/oPgEn9z4ZXa1dkWxIVu2MRO2WzOr68NxAqN3Uf+ueGsBdBZz5LtDhwGZ9agl3pHpKqpokmJJKpDPPPBNDhgwJHiM/v3/yySf48ssv8eeff2LOnDno1KlTk0Prv//+e1WxJLPBZCZYnV69eqlN7aTyqykDBw7E//3f/+HKK68MPuaBBx6oWgrrSIXXRRddhHXr1qlZ4eK///2vqjiTgK0pUnUlIZa0Qx555JEqZJPcYleD+Tdv3qxep7wOeVypYpMqL6nkkqowmf0ltx999FEVQtVlHhJ0SfWZhIdjx45t9PiSf8hjvP/++5g4cWLwOW+//Xbcd9996nZVVZUK4iQolPBs8uTJKuCS56OWFdWvSeQvRd1Aeim1kx7Yhn+Z/H5/o+SWiIiIqL2ocnmjDrt+XV+Ch2atCoZdGRYDHj9tSMSwS74JlsquuAq73NWBuV1t2d/vAr88FbptzgZOeglI7dI616PRAMkSdpkQz9w+dzDoKqkpwWdrP8PsTbPh9gcqJeqYdWZM6jkJU3pNQWdrZyTrk+Nrrh3tv6pLAzO7dLv4t0HWnRWB41pghpdUZEklkgyql+onCXOk1U7aCetce+21KsCSaqXdVRlJNZZUcEmbX30SqEkrY12wI5VNX331FfLz8+H1etX9DXOCQw45pNHjS+tfXdgl8vLyVLgWibQcSqAlAZ9UVO1u872bb74ZGzZsUAHbs88+2+RrlNCtYZWY0+kMvka5pjvvvFNlJDIXTQIvqQxr+BrrB4tms1k9Zt3rkQBQvj7SCikhmuySKaEdtVLgJWlwnYsvvjhY7VVfaWkpjjvuOM7xIiIionZHhtMXRRl2/bWlHPdMXwGfPxB3pSTq8dipQ5CXsvtKHfkBXnZjlF0Z44YMp5cdGSM3DLSeP18HfnshdFvmZU35H5DSufXCLmvHXf8AHAecXqcKumq8NSisKsSnaz/Ft1u+hdcfPhdPKrim9JyCKb2nIM+cB4vewqCL4ou0TcuAeq+r6QovWZf75bgWYDKZ1Lwp+ZCQ5tJLL8Vdd90VFnjJfR988IGanyVtd7siBS4SQjW1o6FUQIkbbrhBPY60Jkrll8ynOvXUU4PtgPUDoIb0en2jfzOjaEbDrbfeqsIluXY5XuZvNUWCOqnAuuSSS1SAJQPuG7YhymuU9sf33nuv0fkyb0vIeycD8mUzP5mNJmHhEUcc0eg1NvV65PGFPLdUnM2YMUNVpklb6L/+9S/1vlHz2uNG+LrWxYYk7ZX/oIiIiIjaE6fHh0K7M6pvrFfk23H7F8vg8QWONRu0eOSUweiWEXkGk1R/JRniaAaR1w1U5rfdsEuu6/eXgT9eDq1J0CQD6q15rXNNGu3OsGvfdyRri6o91bC77Srwynfk45M1n2Du1rnw1YaPPEk1puLkXifjxF4nIsecA7N+/5hhRvuh3KGBGYEyoF5nCrQy1//fqJpyIGdg4LgYGDBggGrNq0/a62TO1tlnn626uKTtsSnSFinthTqdTrUlNkWqySQQktbHuoyg/sD3liLtg3JdEnpJqHTWWWc1eZwU78g8rZtuuqnJ9kd5jR999FFwKP+uXqO0W8rj1M0DKykp2eNrlgBN3iv5kIH7EhYy8Gp+UX/Xdd1116nPEnbJkDUpOawjZXwLFixocrgdERERUVsOuwpsTvijCG3WFzlw8+dL4fQEfkNr0mnw0MmD0Sen8YDchjKTjbBEGGTfrvi8gbCrmQctNxv5es5/HvjrzdBaaldgyouBHdJag1YXCLu04b/1jwfSsigVXdLCuMW+BR+v+Rg/bfsJfoT//ZAdFk/pfQom9piI7KRsJOlDP08QxSWp6Dzq2sBujFINm5gWqO6Uyi4Ju4zJgfvluGYk3VennXaaCnik+kna6WRGl7Q0SgtgQxJQybB4GeIuwZFUZTUk3VxSySTtd7L7Y9++fVXbosylkjVpU5Sqrs8//1wFaHW5QV1VU0uTdkUJseQ1yHM2Va0m87OkOmtXYZacIwP15T2SOV8yz0xaFeU1SSAlt+U1ynslr1fmfMu6VLLtCam2k0oymW8mmwpIC6jsTknNL+rvvGSXBSG//Vy6dKkaDldH/jx06NCodlEgIiIiam9h15ayatz42RI4XIGWLL02AfecOBCDOqZEPDfDbITVFEchh98XCLsk9GqL5Ospu54trteSkt4DOPG/gDnQkhJzUtElM7sk9IoT8jOBw+NQQZe0Km60bcRHqz/Cr/m/ojY43S5Awq3T+pymBtJnJWWp4fRE+40eI4CJTwf+d6lkbWBml7QxSmWXhF1yfzOTnQUPP/xwPPXUU2r2lMfjUXO4ZYi9tAA2RUIuCYokMJKN6k4++eSw+yXAknDrtttuU0GaBEe5ubk45phjkJMT+EWCPJ/cJ/OoZB6YVFJJKBQrEj5J6CW7KNa9loavYXdzyqSoR3ZnlOuW1y8tjx07dlQth3UhmQyalwH9Mni/S5cuePDBB/c4B5H85JZbblHVbxKWSYXXhx9+uJevmvZ5l8b6ZPeEZ555Zr/bqZC7NBIREe2fYVeB3Yl/f7AIxY7AjC9NAnDXpIE4unfkLcRTkwxIN8dR+5q8X/btgdldbVGtH5j3GLD049BaRi/gxBeBpMAGTDEn1RyyG6O0M8YBf60fle5K1bro8/uwrmIdPlz1IRYULGh0bAdzB5ze93SM7zYeGYkZMElLF9H+SiqdChYHBtTLzC5pY2zmyi4i2sfAa3/FwIuIiGj/C7tKHS78+6NFyK8IBTw3H98PYwdEbouzJuqRaYmzweT2/MCujG017Jr7ILAitNU9svoBk18AEgMDlWNOhlQn58XFD7USbknIJWGXhF6rylbhw9UfYmHhwkbHdk7ujDP6noFx3cYh3ZQOgzaOQl8iImo34qeumoiIiKgZwy5btQf/+XRJWNj179G9ogq7LCZd/IVdjqK2G3bJ7n/f3QusnhFak3YhCbtkRk5rMCQFwq4mNntqTzx+j2pblDld8nvyZSXLVOviouJFjY7tbu2OM/qdgTFdxyDNmAZ9HM4rIyKi9oOBFxEREe0X9iTsklldMrNrc2ko4Ln86O448YCOEc81G3XITo6z1q3qMsAZuzkse8TnAebcAaybE1rLGwpMehYwWFrnmoyWwHD8dhx2yQD6+kHX0pKl+GDVB1hWuqzRsb1Se+GsfmdhVOdRSDWlQi/ziYiIiFoZAy8iIiKKe3sSdtV4fLj186VYW+QIrp07rAvOPKxLxHOTDBJ2xVlll6syEHi1RT438PXNwMYfQ2sdDwZOeDpQYdUaTFbAko32yul1qqCrxlujgq6/i/9WM7pWlq1sdGy/9H44s++ZGNFphAq6dBr+aEFERG0H/1UiIiKiuOby+lBojy7scnv9uPOLZViWH6pmOuWgjrjoyG4RzzXptcixGtUuUHHDUxNoZWyLvE5g5g3All9Da12OACY8DrTWcPTENMCcgfao2lOtZnRJ4CVBl8zmkhldq8tXNzp2YMZAVdF1VMejkGpMhTZOBvITEVF8YeBFREREcUsCrEKbCz5/5LDL6/PjnukrsHBLRXBtwuBc/HNkz4ghllGvRa7VFF9hl9cNVO4I7MzY1sgssRnXAtv/DK11HwGMfxhorQHpsgtka+0EuQ+kZVEquqSFUYKuPwr+wAerP1C7LzY0JHMIzu5/No7scCSsBiuDLiIiatMYeBEREVFckgBL2hi9shV8BBKIPTRrFeZvKA2uje6XjWuP6xMxxNJrNSrs0mjiKOzy+4DKfCCK965VWiyn/xsoWBxa63UcMOZ+oLWGpFuyAFMK2gsJthweh6ro8vg8atfFBTsWqIquDbYNjY4/KPsgVdF1eN7hDLqIiKjdYOBFREREcUcCrB1Rhl3S6vjUnDWYu7o4uDa8VwZuGt8XWk3ksCsvxRTxuHZFKrrs+YDPizbHaQOmXQkUrQit9T0BGH0n0BrzoyQMlXldrbUT5F4EXZWeSlXR5fP7VNA1P3++Cro22Tc1Ov6QnENU0HVo7qEMuoiIqN1h4EVERERxGHbVwOPzRxUA/PeH9Zi5rCC4dkjXNNxxwgDotJrdnqvTBMKuSMe1O5UFgNeFNkcG53/5T6B0bWhtwEnAqFuBBE3rhF3JuYDBjLZOgq1KdyXsLjt8tT718ev2X1XQtaVyS6PjD889XAVdB+ccjGRDMlsXiYioXYqz79CIiIhof+b316LA7lSzu6Lx2s8b8flf24O3B3dMwb0nDoRBFznsyo3HsKuqBHBXoc2RwflTLwsPu4acAYy6rfXCLmuHNh92SRVXubMc2yq3qc9uvxs/bP0BV31/FR7989FGYdcReUfg2VHP4vERj2Nk55Fq50UOpCdqny688ELVkt/wY9260Hy+Bx98EFqtFg8//HCj8998802kpqYGb/t8Pjz00EPo168fEhMTkZ6ejmHDhuGNN94IHlNUVIQrrrgCXbp0gdFoRG5uLsaNG4f58+fv8jrvvvtuHHDAAWFrP/30k3ruq666Sv1iSq5Frr1///6Nzv/444/Vfd26Rd5chvY/rPAiIiKiuCDfFEvY5fL4ojr+3d824/3ftwZv981JxoMnDVK7Le6OtC/mpBgjhmLtTk1F4KOtkfbKL/4B2EPBJA66ADjiqkDwFGsaDZDcAdC30k6QUfD6vWo+l8PtUNVdEnzN2z4PH63+CNsd28OzOySoIfRn9zsbQ7OHqoouTWuEiERxTv5bXFm2EhXOChUm90/v3+L/rY0fPz4skBJZWVnBP8t9N954I15//XXcfPPNu30sCaZefvllPP/88zjkkENgt9vx559/ory8PHjMKaecAo/Hg7feegs9evRAYWEhvvvuO5SVlUV9zTNmzMBpp52GG264Affcc09w3Ww2q0BNwrMjjjgiuC7XLgEbUVMYeBEREVFchF2FdhecUYZdnyzchtd/Cc0s6pFlxiOnDIbZuPtvjTQJCcixmmDU7T4Ua3ekqkuqu9qa8s3Al/8HOApDa4ddARx6WSuFXVrA2hHQtdJOkBF4/B41n0t2XpT/JiTo+nHbjyroyq/KbxR0Hd3xaNW6ODhrMIMuohYkm0K8tvQ1bLRvVIG0TqNDd2t3XDL4ErUZREupq7Jqyo8//oiamhrce++9ePvttzFv3jwcc8wxu3ys6dOn45///KcKo+oMHTo0+OeKigr8/PPP+OGHHzBixAi11rVrVxx22GFRX+/777+Piy66CI899hiuvvrqsPt0Oh3OPvtsFXDVBV7btm1Tz3fttdfigw8+aHS9EtItX74cHTp0wAUXXIDbbrtNPY548sknVeC3YcMGVa02adIkPProo7BYLOp+qSq75ppr8NFHH6nPW7duxVFHHaXOycvLU8fIc0tgKM+h1+sxcOBA9RrkdVPbwF/fEBERUbtXXOlCtTu6IevTFufjxR/WB293TkvEY6cOgTVx9zv8ScuEtDFGqgBrdzzOwNyutqZ0XaCNsX7YdeS/gcMub52wS6sDUjq1ybBLdlosqSlBviNfVXV5fV58u/lb/N93/4en/noqLOzSQIMRnUbgxeNexINHP4jhHYcjxZjCqi6iFgy77p1/L9aUr0GSLgmZiZnqs9yWdbm/Nbz22ms466yzVFAjn+X27khw9v3336O4OLTBS30SFMnHF198AZdrz+dAvvDCCyrskutoGHbVueSSS1QAVV1dHQylpIotJycn7LhvvvkG5557rnqcFStW4KWXXlLHPvDAA8FjNBoNnn32WSxbtkxVpMlrk/CqPnmexx9/HO+8844KBLds2YL//Oc/6j6v14spU6aocG/JkiWq8uzyyy+PuLMzxRYDLyIiImrXiuxOOFzRhV2zlxfg6W9Dc6Bk6Pzjpw1FWpIhcthljcOwS3ZirNwR2JmxLSlaCUy9HKguDa2NuAk46PzWuR4JuVI6A9rdh6Kx5va5UVxdrNoUJeiS4GvO5jn4x7f/wDN/P4MdVTvCgq6RnUbif2P+p4IuaWOUoIs/nBG1bBujVHZJ1WV2UjZMOpMKl+Wz3JZ1uV+OawlfffVVMIiSj7rqLGlH/Oyzz1QoJOTzp59+qtZ3RSqiJOyS4GvIkCH4xz/+gVmzZgXvl8opCZUkPJL5W8OHD8ett96qwqBIVq5ciSuvvBIvvvhi8JqaIrO+evbsqa61brbXxRdf3Og4CbakRVOquqS1csyYMbjvvvtU8FVHqrZGjRqF7t2749hjj1X3yzyw+qQ983//+59q4TzooIPUNUqLZt17aLPZMHHiRHVNMl9Mno/tlW0LAy8iIiJqt4oqow+7flhdjEe/WR28nWUx4vHThiAr2bjb8yQQyE42ItEQZ2GX3w9U5gP+6NpAY2bHYuCLKwCnLXBbZtyMvhsYfHrrXI/M6rJ2CrQzthFOrxOFVYWqokt+YJYWqdmbZqug69m/n0VBdUFY0HVs52NV0PXA0Q9gWN4wWA1WBl1EMSAzu6SNsalwWW7Lutwvx7UECXQWLVoU/JCKJiFtdxIE1bUkSpAktz/88MNdPtaAAQNUNdRvv/2mKrFkPpe0AV566aVhM7zy8/Mxbdo0NaxeWv4kKJJganc6deqkjpOWwh07QkF9UyTgkrZCacl0OByYMGFCo2MWLlyoWjXrh32XXXaZeuy66rC5c+eqIKxjx45ITk7G+eefj9LSUlRVhTZuSUpKUmFWHWlllDliQtogZWMAeZ3yPjzzzDMRr51ij4EXERERtds2RoczurDr1/UleGDmSvh3FjKlJelV2JWXkhjx3EyLIeJsr3ZHKrqkssvrRpuy7Xdg2r9CO0VKyDT2AaD/pNa5HkNSYGaXDKpvA2q8NSioKlAf8ue6oOuKb6/Ac4ueQ2F1qP1TqkhGdxmNl8e+jHuH38ugi6gVyIB6+e/UoG26iljW5X45riXIoPdevXoFP+pmT8kcLJk7JVVZdR9yO1Jbo7QBHnrooWpm1tSpU1WQJeds3LgxeIzJZFJB0p133olff/1VhUJ33XXXbh9XAqdvv/1WfR45cqQKzXblnHPOUaGbzOeSkKpuJld9fr9fDbyvH/YtXboUa9euVde3efNmFZQNGjRIVbpJQCYtlXVVXXWk3bNhSCmVZXUkeJNWxiOPPFK1Wvbp00ddG7UdcfbdGxEREe0vYVelM/RN6e4s3FyOe6avgG9n2mU16VQbY+f0pIjnZiYbkWxqW21szaKqGPDUoE3Z/Asw8wbAt3P2i0YPHP8o0H3XQ5RblNECWHJaZ15YA9WearXrolR21Q2n/37L9/h4zccoqg5UG4QFXZ1H48y+Z6JvRl9WcxG1ItmNUQbUS/uxtDE2JOtyvxwXKxL8yO6KUn0lVUr1h87L0Hqp4pIgKBpS9SXqV0U1dYzM9YokLS1NhV5SMSWhl1RgSfVVQ3LNkydPVu2H0m7YFKkWW716tQr5miKvX2ZwPfHEEyrEEw3bGaN14IEHqo9bbrlFDdOX6rlhw4bt1WNR82PgRURERO1KiSP6sGvxtgrc/sUyeHyBsMts0OLRU4ege6Y54rkZZiOs8Rh2VZcBzl3PaWkV674FZt8G+HdW7OmMwIQngS6t9EODKQWwZKEtBF2y66JrZwi4u6BLm6DFsV2ODQRd6Qy6iNqC/un91W6MMqDeqDWGtTVKpZD8990nrY86LlakIkt2TmxqR0YJbOT+p556qtF9p556qprLJdVMMsdLqrok5JGqpn79+ql2QJkRJi2HMuNLqrUkWJI2xRNPPDGqa0tJScHs2bPVIPq60EvaHRuSyrL//ve/yMjIaPJxpLpMZmt17txZXZOEWjJLTMK++++/X7UpSuD13HPPqXbEX375ZZfh2a7I63/55ZdV+Ca7QErAtmbNGlV1Rm1H26jPJiIiIopCqcMFe010YdeKfDtu/XwZXN7AMGCTToOHTh6MPjnJEc+VIfYpSXEYdrkqA4FXW7JyOvDNLaGwS28GJr/QemFXYlqrh10yl0vmc0moJWGXtDx9s+kbNaPr+UXPh4VdEnSN7ToWr4x5BXcdcRcOyT2Ew+iJ2gipuLxk8CUw683qv1up0pQB9fJZbsu63C/HxYLb7ca7776rZm01RdblfjmuIam8mj59ugqIJOSSAe0SdElAJW2FMifr8MMPV2GZhGlSJXbHHXeo2VnPP/981NdotVrVLouy86KEXlu3bm10TGJi4i7DrrprlYH9c+bMUS2YUnElQ/e7du0anFkmtx955BF1ne+99x4eeugh7AmZ77Vq1Sr1nsn7ITs0ylD7K664Yo8eh1pWQm39JlTaJdmFQRJn2YlB/iMkIiKi2CqrcqOiOrqZU2sKK3H9J4tR5QoMZDfoNHjwpEE4qEtaxHNTEvXIsOx+kH27JC2M9vy2tSPjko+BeY+EbhtTgMnPATkDW+d6zBmBwKuVyE6LNrdN7bYoJOj6bst3u6zoOq7Lcaqiq3d6byQbkmP2QzMR7ZkFOxao3RhlQL38dy1tjFL5JWHX4XmH8+0kaiEMvKLEwIuIiKj1lFe5UR5l2LWxpArXfrQI9p0D7XWaBNw/ZRAO6x6aVbIrMq8r0q6N7ZIMp7dvC+zM2FYsfBOY/1zodlIGcOJ/gYymZ660KGkzMmcBptj/UlN+9+zwOFRrk/wgLBh0EcUfqeyS3RhlQL3M7JI2RobURC2LM7yIiIioTZOqrmjDri1l1fjPJ4uDYZcmAbhz4oCowi6LURefYZffB1Tmt52wSyrMfvsvsPD10JoMh5/yPyC1S+uEXcm5gCHyXLfmDroqPZWwu+xhQZfM6PpozUe7rOg6o+8Z6J3WG1ajlT8sE7UjEm4NzGil6lWi/RQDLyIiImqzbDUe1coYjfyKGhV2lVd7gmHXbRP646jemRHPNcdt2OUPtDH6ds7Ham21fuCnJ4AlH4bWUjoDU14EkvNaJ+yydgD0iTENumTHRfnwSRi5M+iau3UuPlr9EQqrC8OOZ9BFRES0dxh4ERERUZtkd3rUkPpoFNqdamZXiSMQjsk+WDeO64tR/bIjnptk0CE7OXz3rLjhKAC80b2HLU7CnbkPACu/DK2l9wy0MZojh5LNTqMNhF2yI2SM2pkq3YGKLl9tIOiSwKsu6CqoLmgUdI3uMlpVdMkubqzoIiIi2jMMvIiIiKjNcbi8KKmMLqgpcbjwn0+WoNAeOv7aMb0xdmBuxHMTDVrkWOM17CoG3NVoE2QI+7d3Amtnh9ayBwYG1JtSYn89Wh1g7Qho9a0adMkw+h1VOxq1PUnQJcPoJeiSYfRaCeeIiIhojzDwIiIiojalyuVFcZRhl8z2uuGTJdheURNcu3JUT0wc0iHiuSa9FjnJpvgMu6rLAKcNbYLXCcy6Edj8S2itw0HAxKcAgyX216MzAMkdAqFXCwddEnJJ2FU/6Ppx24/4cPWHTQddnUfjjH47K7oMVgZdRERE+4CBFxEREbUZ1W4viipdas5RNPO9JOzaXBaqYrrs6O44+aBOEc816rXItZqgkUFf8cZVGQi82gK3A/jqWiD/r9BalyOA4x+L6dysIL0pEHZpNC32FBJqyXwuCbok9FJrtT7M2zYPH676EPlV+Y2CrlGdR+GsvmehTzqDLiIioubCwIuIiIjahBq3T7UlRhN2VTo9uOHTJdhQUhVcO/+IrjjrsMi7/Bl0mvgNuzw1gCN8d79WU1MOTL8aKFoRWus5Ghj7QExaCRuRXRhlN8YWqujbVdD18/afVdC1zbEt7HgNNBjZeSTO6ncW+qb3ZUUXERFRM2PgRURERK3O6ZGwyxlV2CUtjzd9thTrihzBtbMO64wLjuga8Vy9VoO8lERo4zHs8rqByh2yDWDbmB827Z9A2YbQWv/JwKjbAE0rfPspc8IsWS0WdNncNjjcjmDQJZ9/2f4LPlj9AbZWbm0UdI3oPAJn9jsT/dL7IcWQwtZFIiKiFtBy9dxEREREUXB5fSiwOeGPIqiRKrBbPl+KVQWVwbVTD+6IS4/qHnEWVyDsMsVn2CVD4e3bAX8gcGlVtm3A55eEh11DzwKOvaN1wq6k9BYJu7x+L8qcZapyS2Z1SchVF3RdPfdqPPrno2FhVwISMLLTSPz3uP/i7iPvxuG5hyPdlM6wi4hajfy7+cUXX+zy/m7duuHpp59GPPjhhx/U662oqIj6nAsvvBBTpkwJ3h45ciSuueYaxNqmTZvUtS9atCjmz93eMfAiIiKidhF2SRXYbV8sxbJ8e3DtxAM64P9G9Iw67NJp4/BbH593Z9gVGIzeqiTk+vzSwPXUOfQy4KjrgYRWeO8l6JLAqwWCru2O7SrokqpE+ZifPx//nvtvPPzHw9hs3xwWdB3T6ZhQ0JXHoIuIWl5RURGuuOIKdOnSBUajEbm5uRg3bhzmz58fF29/XQik0+mwfXu9f3MA7NixQ63L/XKcOPLII9V6Skr0OwM/88wzePPNN1s9eNtb3RoElvJv1fXXX4/k5GR8//33wRBPrufhhx9udP6ECRPUfXfffTfaK7Y0EhERUatwe/0q7PL5a6M69o4vl2PR1tDOgxMG5+KqY3tFDLt0Gg1y4zXskpBLwiUJvVpb0Upg2r/Cd4ccfi1w4Lmxvxb5OyHzumRuVzMGXTaXDQ6PI9h6K5//KPgD7616Dxts9Sradjqq41FqRteAjAFINaZC1xoVbkTUJtT6/XCuWAlfeTm0aWkwDeiPhBbcQOOUU06Bx+PBW2+9hR49eqCwsBDfffcdysrayKYmO7ndbhgMhr0+v0OHDnj77bdxyy23BNfkNXfs2BFbtmwJrslzSOi3J/YkHGvrfD4fLrvsMkyfPl2FXYceemjwvs6dO+ONN97AzTffHFzLz89Xx+Xl5aE9i8Pv/IiIiKit8/iiD7vk2LunL8fCzeXBtbEDcnDdmD7QRBl2SYVX3JH2RRV2eVr7SoDtfwFTr6gXdiUAo25vnbBLfoC0dmi2sEuCrtKaUlXRJQPp6yq6/iz8E9f9eB3uW3Bfo7DryA5H4rlRz+G+4ffhiA5HIDMxk2EX0X6s6rffsOXSy7DtqquQf8st6rPclvWWINVDP//8Mx555BGMGjUKXbt2xWGHHaZCoRNOOGGX5917773IycnZZeuczWbD5ZdfjuzsbFitVhx77LFYvHhx8P7169fjxBNPVI9hsVhUqPLtt982qjq6//77VbugBEoSwkgVVWpqKr755hv0799fnTt+/HhVkRXJBRdcoMKa+uTxZH13lVXRPGfDlkbh9Xpx5ZVXqnMzMjJw++23h80ffffdd3HIIYeoKioJ2M4++2xVbSek2ky+HiItLU1djzyH8Pv96uvVq1cvVZEnlXkPPPBA2HNv2LBBnZ+UlIShQ4dGXa3ncrlw2mmnYc6cOZg3b15Y2CUmTpyI0tJS/PLLL2Hv4dixY9XXumFAeeONN6pA0Ww24/DDD1fvbR15nLPOOgudOnVS1zl48GB88MEHYY8hVWVXX321epz09HT1PjWsIpPbddWJEmrK8XsjDr/7IyIiorbM6/NjR4UT3ijmTcmx9321Er9tCP1GelTfLNwwrm/EsEtmdUnYJbsyxh157yrzA4PqW9umn4BpVwKenTtmarTAuAeBgSfF/lrkua2dAH1iswRdJTUljYKuv4r+wg3zbsA98+/Buop1YecMyxuGZ0c9i/uH34/hHYeroEuvaYUdKYmozZBQa8ddd8G1ejU0SUnQZWWpz641a9R6S4ReEt7Ih8znkrAjEvnftn//+9947bXXVFB2wAEHNHmMhGUFBQWYOXMmFi5ciIMOOgijR48OVo05HA7VBich199//61aKCdNmhRWaSUee+wxDBo0SD3GHXfcodaqq6vx+OOP45133lGhjJzzn//8J+K1T548GeXl5eq6hXyW65HnjWRvnlOqx6RdcsGCBXj22Wfx1FNP4dVXXw0LhO677z4VBMr7v3HjxmCoJZVUn332mfrz6tWrVbgmbZNCwkgJvOT9WLFiBd5//30VHNZ32223qeuTQLJPnz4qWJIAbnccDof6ui1fvlwFWhLuNSTVb+ecc05YcCiB18UXX9zo2Isuukg9zocffoglS5aoIE2CwrVr16r7nU4nDj74YHz11VdYtmyZCkjPO+889X41fB8lMJP1Rx99VIWtEsiJTz/9VL2vL730knpceR8lONsbrKsmIiKi2IZdtujCLqn+emDmKvy8riS4dnTvTNxyfL+Ig+fjOuyS3yTLboweZ2tfCbB6FvDdXaH5YVojcPwjQLejY38tWj1g7Qho9+3bW4/fo1oXqzxVYb+1X1K8RLUurihd0eicQ3MOxdn9z8aQrCGqddGg3fv2HCKKrzbGkpdfgd9RBV1OTrAFP8FkQoLRCG9Rkbo/6bDDmrW9UQIZCSykeup///ufCqZGjBiBM888E0OGDAk7VgKT888/H3/++acKMqQypylz587F0qVLVbWSVN0ICYskjJCAQoINqTqSjzpSyTV16lRMmzZNVUXVkcqw+sGShFTSfinX2rNnT7Umx0sIEoler8e5556L119/HUcddZT6LLdlPZK9eU4JrSSMka9l37591Xsit+W9FvVDImkllVBMquskeJIQUiqahFROSZWYqKysVMHX888/H6xMk2uS11OfvGd1FXr33HMPBg4ciHXr1qFfv367vN777rtPVZtJiNawWqu+Sy65RD2fXIcEkVLNJ89Vv/JKKvikWmvbtm2q6qrumr7++msVlj344IOq8qv+1/aqq65S93/yySeqGqyO/D2866671J979+6tXru03I4ZM0YFj1L1ddxxx6mvo1R6yXu4N+Lwu0AiIiJqiyTAkrBLWhSjOfbhWavw45ri4NqwHum4/YT+EWdxSeVXjtUEo06L+Ay7CgBPTWtfCbD0E2DOHaGwS1oIJz/fOmGXzgikdNqnsEuCLqnoynfkw+EOzelaXroct/18G2775bZGYddB2QfhiRFP4KGjH8LRnY5GdlI2wy4iCpKZXe6NG6FNTW00b1Jua1NS1P1yXEvM8JI5TBI2SaWVtJ1J8NVwCPu1116rWuN++umnXYZdQkIQCW2kja+ugkw+pIJJghBRVVWl2tQGDBigwhy5f9WqVY0qvKTlryFpf6sLnoTMjqprBYxEwhoJVKT6TD43VZnUlL15zmHDhoV9LY844ghVhSQzsoRUtklbp7SRStAk7Xui4XtQ38qVK1UlnlTL7U79sLJutlak6x07dqz6ukgYFemxJXiS8FJCQ6nKahga/vXXX+rfRqkuq/934Mcffwz+HZD3QVox5fHq/q7Mnj270etvGLzWf++laqympkYFhhIkSmgaqZJtV1jhRURERDEKu2qiCrtkx8bHvlmN71aFvok7rFsa7p40MOIsLgm7pLLLpI/DsEs4CgH3ztbB1iJB0MI3gN9eCK2ZUgNhV3bjVokWZ0gCkvMCg+qbsaJrVdkqvLfyPSwqbjzLZmjWUJzT7xwcmHOgqugy6Uz79BKIKD7JgPpajwcJuxjKLuu1Nps6riWYTCZVMSMfd955Jy699FJVVVPXYifkPqnakVlW0ta2KzJjSkKJ+vOa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rMIu2d9VJNTyN/mw6OElcFV5UFBmx5zLD2VlFxEREREREVEP1KOG1k+bNk2t0hg1btw4nH766SmH1mf6Ko1RL1x8L0b1+hyTc/+j2x4IW7HBPRurmuYDXhO+96dj8eLtX2Gvv7xZWCVh1/fu/X6b36avwYMP7/oQrlo/CootmHv93C6p7CIiIiIiIiIi6tGB13PPPYcLL7wQ999/P2bOnIkHHngADz74IFatWoXBgwd328BLeNcthu2Z49Xten8JtntmYL1rJux2E465cRZsZQMAR5EsgQivqwkL//gfuFwhFBQYccyvT9pvZRcRERERERERUXfSYwIvIdVdf/nLX1BRUYEJEybgH//4B4466qg2nZvJgZfy0Z+A0pFAn0kyrEy72AsBuwRdPaJzlYiIiIiIiIio5wVeByPjAy9RtwMI+bWQS8Ku5Kn1REREREREREQ9AEt/somjGLDkAEZjVz8TIiIiIiIiIqIuw8Arm9jyuvoZEBERERERERF1OZYCERERERERERFRVmHgRUREREREREREWYWBFxERERERERERZRUGXkRERERERERElFUYeBERERERERERUVZh4EVERERERERERFmFgRcREREREREREWUVBl5ERERERERERJRVGHgREREREREREVFWYeBFRERERERERERZhYEXERERERERERFlFQZeRERERERERESUVRh4ERERERERERFRVmHgRUREREREREREWYWBFxERERERERERZRUGXkRERERERERElFUYeBERERERERERUVZh4EVERERERERERFmFgRcREREREREREWUVBl5ERERERERERJRVGHgREREREREREVFWYeBFRERERERERERZhYEXERERERERERFlFQZeRERERERERESUVRh4ERERERERERFRVmHgRUREREREREREWYWBFxERERERERERZRUGXkRERERERERElFUYeBERERERERERUVZh4EVERERERERERFmFgRcREREREREREWUVBl5ERERERERERJRVGHgREREREREREVFWYeBFRERERERERERZhYEXERERERERERFlFQZeRERERERERESUVRh4ERERERERERFRVmHgRUREREREREREWYWBFxERERERERERZRUGXkRERERERERElFXMXf0EuotwOKyuXS5XVz8VIiIiIiIi6iD5+fkwGAz8eBJlGQZebVRfX6+uBw4cmM7PBxEREREREXUip9OJgoICfsyJsowhHC1dolaFQiHs3r0749J/qTiTEG7Hjh38Jt0D8fPfs/Hz37Px80/8N9Cz8fPfs/Hz37Ey7W88IuoYrPBqI6PRiAEDBiBTySsSfFWi5+Lnv2fj579n4+ef+G+gZ+Pnv2fj55+IqGUcWk9ERERERERERFmFgRcREREREREREWUVBl7dnM1mw+23366uqefh579n4+e/Z+Pnn/hvoGfj579n4+efiGj/OLSeiIiIiIiIiIiyCiu8iIiIiIiIiIgoqzDwIiIiIiIiIiKirMLAi4iIiIiIiIiIsgoDLyIiIiIiIiIiyioMvDLcv/71LwwdOhR2ux3Tpk3DJ5980urxixYtUsfJ8cOGDcP999/fac+Vuv7fwEcffQSDwdDssnbtWn56uqGPP/4Yp556Kvr166c+j6+++up+z+H3gJ77+efXf3b54x//iOnTpyM/Px+9evXCGWecgXXr1u33PH4P6Lmff34PyB4LFizApEmTUFBQoC4zZ87E22+/3eo5/NonImqOgVcGe+6553D99dfj1ltvxTfffIMjjzwSJ554IrZv357y+C1btuCkk05Sx8nxt9xyC6677jq89NJLnf7cqWv+DUTJL8UVFRWxy8iRI/kp6YYaGxsxefJk/POf/2zT8fwe0LM//1H8+s8O8sfrNddcg8WLF+O9995DIBDA/Pnz1b+LlvB7QM/+/Efxe0D3N2DAAPzpT3/CkiVL1OWYY47B6aefjlWrVqU8nl/7RESpGcLhcLiFfdTFDj/8cEydOlW9yhM1duxY9SqfvPKX7Oabb8brr7+ONWvWxLZdffXVWLFiBb744otOe97Udf8G5NXduXPnora2FkVFRfxUZBGp8HnllVfU574l/B7Qsz///PrPbvv27VOVPhKEHHXUUSmP4feAnv355/eA7FZSUoI777wTl19+ebN9/NonIkqNFV4ZyufzYenSperVvERy//PPP095joRayccff/zx6pUhv9+f1udLmfFvIGrKlCno27cv5s2bhw8//JCfnh6C3wNI8Os/OzmdztgfvS3h94Ce/fmP4veA7BIMBvHss8+q6j5pbUyFX/tERKkx8MpQVVVV6gdc7969ddvl/p49e1KeI9tTHS9l8PJ4lP3/BiTkeuCBB1Qb68svv4zRo0er0EtmAVH24/eAno1f/9lLivFvuOEGHHHEEZgwYUKLx/F7QM/+/PN7QHb57rvvkJeXB5vNpjo2pMp33LhxKY/l1z4RUWrmFrZTBrWxJP/Sk7xtf8en2k7Z+W9AAi65RMkrgTt27MBf//rXFlsgKLvwe0DPxa//7PXTn/4U3377LT799NP9HsvvAT3388/vAdlFPp/Lly9HXV2deiHz4osvVi2tLYVe/NonImqOFV4ZqqysDCaTqVklT2VlZbOKn6g+ffqkPN5sNqO0tDStz5cy499AKjNmzMCGDRvS8Awp0/B7ACXj13/3d+2116r5nNKeLoOsW8PvAT37858Kvwd0X1arFSNGjMChhx6q5rbKIiZ33313ymP5tU9ElBoDrwz+ITdt2jS1Mk8iuT9r1qyU50g1T/Lx7777rvpBabFY0vp8KTP+DaQiqztKmwNlP34PoGT8+u++pJpXKnukPX3hwoUYOnTofs/h94Ce/flPhd8DsuvfhNfrTbmPX/tERKmxpTGDybyGCy+8UAVW8oNMZjNt375d9fGLX//619i1axcef/xxdV+2y/L1ct6VV16pBlg+/PDDeOaZZ7r4PaHO+jdw1113YciQIRg/frwaev/kk0+qMni5UPfT0NCAjRs36pYdl/YGGVo8aNAgfg/Icu39/PPrP7tcc801ePrpp/Haa68hPz8/Vu1bWFgIh8OhbvP3gOx1IJ9/fg/IHrfccgtOPPFEDBw4EPX19WpovazC+c4776j9/NonImqjMGW0++67Lzx48OCw1WoNT506Nbxo0aLYvosvvjg8Z84c3fEfffRReMqUKer4IUOGhBcsWNAFz5q66t/An//85/Dw4cPDdrs9XFxcHD7iiCPCb731Fj8h3dSHH34oQ/iaXeTzLvg9ILu19/PPr//skupzL5dHHnkkdgy/B2SvA/n883tA9rjssstiv/uVl5eH582bF3733Xdj+/m1T0TUNgb5v7aGY0RERERERERERJmOM7yIiIiIiIiIiCirMPAiIiIiIiIiIqKswsCLiIiIiIiIiIiyCgMvIiIiIiIiIiLKKgy8iIiIiIiIiIgoqzDwIiIiIiIiIiKirMLAi4iIiIiIiIiIsgoDLyIiIiIiIiIiyioMvIiIiHqwSy65BGeccUZXPw0iIqJmPv74Y5x66qno168fDAYDXn311XZ/lMLhMP76179i1KhRsNlsGDhwIP7whz/wo03UA5i7+gkQERFR17n77rvVHwNERESZprGxEZMnT8all16Ks88++4Ae42c/+xneffddFXpNnDgRTqcTVVVVHf5ciSjzGML8LZeIiIiIiIgymFR4vfLKK7qqZJ/Ph9tuuw1PPfUU6urqMGHCBPz5z3/G0UcfrfavWbMGkyZNwsqVKzF69OgufPZE1BXY0khERNQDvPjii+qVbYfDgdLSUhx77LHqlfPElsatW7eqPyiSL9E/HMTnn3+Oo446Sj2OtIVcd9116nGIiIg6m1R+ffbZZ3j22Wfx7bff4pxzzsEJJ5yADRs2qP1vvPEGhg0bhjfffBNDhw7FkCFDcMUVV6CmpoafLKIegIEXERFRlquoqMB5552Hyy67TL3a/dFHH+Gss85q1sooAZYcG7188803KhyTgEt89913OP7449W58ofFc889h08//RQ//elPu+g9IyKinmrTpk145pln8MILL+DII4/E8OHDcdNNN+GII47AI488oo7ZvHkztm3bpo55/PHH8eijj2Lp0qX43ve+19VPn4g6AWd4ERERZTkJrwKBgAqqBg8erLZJtVcyk8mEPn36qNsej0dVfs2cORO//e1v1bY777wTP/zhD3H99der+yNHjsQ999yDOXPmYMGCBbDb7Z36fhERUc+1bNky9cKNDKNP5PV61Ys1IhQKqfsSdkWPe/jhhzFt2jSsW7eObY5EWY6BFxERUZaTgb/z5s1TIZdUaM2fP1+9ul1cXNziOZdffjnq6+vx3nvvwWjUCsLlVfGNGzeqWSlR8seG/EGxZcsWjB07tlPeHyIiIvnZIy/UyM8muU6Ul5enrvv27Quz2awLxaI/q7Zv387AiyjLMfAiIiLKcvKHgARXMn9LVqq69957ceutt+LLL79Mefzvf/97vPPOO/jqq6+Qn5+v++PiRz/6kZrblWzQoEFpfR+IiIgSTZkyBcFgEJWVlaqlMZXZs2erCmdpf5SWR7F+/Xp1Ha14JqLsxVUaiYiIehj5A0F+0b/hhhvULC5Z2erVV19V+1566SU17+vtt99WVWGJzj//fOzZswcffPBBFz1zIiLqSRoaGlRlcTTg+vvf/465c+eipKREvdBywQUXqKH1f/vb39T+qqoqLFy4UFU0n3TSSeqFmunTp6uKr7vuukvdv+aaa1BQUKBeACKi7Mah9URERFlOKrn+8Ic/YMmSJaqF4+WXX8a+ffuatSDKsu0XXXQRbr75ZowfP16FW3KJrmYl27/44gv1x8Ly5cvVKlivv/46rr322i56z4iIKJvJzy0JsuQi5IUauf2b3/xG3Zfh9PJz68Ybb1Ttiaeddpr6mSeLsAhpyZeVGsvKytQCLCeffLL62SerOhJR9mOFFxERUZaTlRl//vOfqwG/LpdLVXdJSCWrK15yySWxCi9ZvUqWeE8mQ+llZUfx9ddfq3ZICb5kfpe0iJx77rm45ZZbuuA9IyIiIiJKjYEXERERERERERFlFbY0EhERERERERFRVmHgRUREREREREREWYWBFxERERERERERZRUGXkRERERERERElFUYeBERERERERERUVZh4EVERERERERERFmFgRcREREREREREWUVBl5ERERERERERJRVGHgREREREREREVFWYeBFRERERERERERZhYEXEREREREREREhm/x/TZVw/fx1d/MAAAAASUVORK5CYII=",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.lmplot(data=scaling_df, x=\"size\", y=\"time\", hue=\"algorithm\", order=2, height=10)"
]
},
{
"cell_type": "markdown",
"id": "ec10f642-5eae-49c9-ab87-eb7da3f5ef4d",
"metadata": {},
"source": [
"Here we see EVoC showing its real strengths. As data set sizes scale up, it comtinues to perform extremely quickly, on par, or even better than, MiniBatchKMeans, and distinctly faster than classic KMeans implementations, including FAISS fast version.\n",
"\n",
"Lastly, let's have a look at quality. We shouldn't expect to see too much here, this is data that should be very easy cluster, so we should expect mostly perfect results. However, some implementations are making trade-offs and approximations, so we should expect to see some of those differences shown here."
]
},
{
"cell_type": "code",
"execution_count": 29,
"id": "c0e7c2ab-e1d2-478f-a9a9-01a762d8da3b",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-26T09:38:21.089635Z",
"iopub.status.busy": "2026-03-26T09:38:21.089471Z",
"iopub.status.idle": "2026-03-26T09:38:21.375853Z",
"shell.execute_reply": "2026-03-26T09:38:21.375331Z",
"shell.execute_reply.started": "2026-03-26T09:38:21.089621Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
""
]
},
"execution_count": 29,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAPdCAYAAACXzguGAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAlCtJREFUeJzs3QeYXVW5P+BvSmZSZ0jvlVACIUAChiLSQxe8KCAKiCgiAgLXhnj1iv7Fq6jYQJGiSJWqYOi9QxJKgJAQEtJ7mUmd/n/2HjPJZM4gxOy0ed/nOU9y1t77nHX2hJDfWWt9K6+urq4uAAAAgI0uf+O/JAAAACB0AwAAQIaMdAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGSkxYXuZFvy8vLy9FcAAADIUosL3cuWLYvS0tL0VwAAAMhSiwvdAAAAsKkI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNC9FVtVWROPvj0vHn9nXqyuqmlyvHx1VcxcsjLq6uqiJXt9xtL45xtzYsbilZu7KwAAbICp4xfGA38cH6+MntrQtmJpRbw/fmEsnrOiyfnVVTUx453FMee9sqirbfpv4fnTytPjVRVN/w29ekVVLJi+LCpWVTc5lvy7unzhqqhYWeXn2Ixc9ztRU1MbZQtWReXqHPe1ti6957l+lg0/r7cX57w2+XlMe3NR+uv6kp9T8mdk7pSyhrYpr82P0X94I8Y8sPbPUtby6jZjInv66afj5z//eYwdOzbmzJkT99xzT5xwwgkfeM1TTz0VF198cbz11lvRq1ev+Na3vhXnnHPOh37P8vLyKC0tjbKysigpKYmt1cNvzY1v3PF6lP/rD16ndkXx61P2iAN26BorKqrj+39/K/7x+qyoqqmL/p3bxqVHD4lRu/bYpH1curIy7nt9dixYXhn7Duoc+27feaO/R2V1bfzhqffi3ldnpV88HDqke1x42A7RuX1xLFlRGWf/dUy88v6S9Nz8vIhTPtYvfnz80MhPngAAsEVLosoN33o2Vi1rHHK3H941pr62MGr/FfD67do5jvjSrlHUpjAmj50fT97yTlSsqP93cmnXNnHkV4ZGlz4domzBynjgj2/GopnL02NFrQti/8/sELvs3yt9refvnBxvPjMraqpqo7AoP4Yd3Cf2OWH7yMvLi/ffWBjP3vFuGhyTf0tuP6JbHHjqTlHcpjANlG88NjMmvTI3amvqYtCeXWPPw/tFUevC9H2qKmvivXHzY/niiuixfWn02anjBt+TpJ8VK6qiuG1h5BdsujHU5H3ffmZWvDtmfhqSk8+424F9oqBVfvp83MPT4o0nZsbKssroPrAkvW9rPuebT8+Kl++fGqvKK9P7usvHe8X+Jw5O+z9jwuJ44q/vxLLFq9Nzu/brEId/cZfo2KNdeq+TL1vW/LxaJT+vEwfHrgf0jprq2njsLxPi3THzIuoi8vIidhzZIw4+becoKMiP1x+fES/e+15UV9am13bq1S6WL1kdlavWftGSlx9x4rf2iu4DSrbd0P3AAw/Ec889F8OHD48TTzzx34buqVOnxtChQ+PLX/5yfOUrX0mvPffcc+PWW29Nr28poXvh8orY/6ePR0V1/R+gNdoXF8YLlxwS373nzTTsrqswPy/u/dr+MbR3acwrXx1/e2VGzFyyKnbvu12csGevaFtU/xdCYtbSVfHa9KXRc7vWMbzfhv2FMHba4vjCDa/EsnW+jTpy1x7x+88Nj4INCLzjZ5bFg2/NicL8/Dhu954xuFuHtP2rN42NB96c2+jcwd3ax/3nfzy+eecbTe5D4scnDI3P79N/gz4XAACbzj2/GBez3136oc4dsn/P2OuoAXHzD15Mg++62ncqjtN+tG/87fIxDQFujSSsfeaSvWPaW4vipb9PafK6H//MDtFn547xt5+80uR1B+7eJY7+6rA0GE55dUGjYz0GlcSnvjEils5dGX//9atpGF2j/9DOcdQ5u0VBYX46ejvppbmxYOby9AuCIfv1jDbti3J+xjefmhljHpiWjvK36dAq9jisXww/4t//u7Z80aqY9PK8qFpdk753rx22i4/q4WvfTAP3uvrt0imOPX/3eOkf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EboBAAAgI0I3AAAAZEToBgAAgIwI3QAAACB0AwAAwNbFSDcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAAGBbDd1XXXVVDBw4MFq3bh0jRoyIZ5555kNd99xzz0VhYWHssccemfcRAAAAtrrQffvtt8eFF14Yl156abz66qtxwAEHxFFHHRXTp0//wOvKysri9NNPj0MPPXST9RUAAAA+qry6urq62ExGjhwZw4cPj6uvvrqhbciQIXHCCSfE5Zdf3ux1p5xySuywww5RUFAQ9957b7z22mvNnltRUZE+1igvL4++ffumwb2kpGQjfhoAAADYQka6KysrY+zYsTFq1KhG7cnz559/vtnrbrjhhnjvvffiBz/4wYd6nyS8l5aWNjySwA0AAADbdOheuHBh1NTURPfu3Ru1J8/nzp2b85p33303vvOd78TNN9+cruf+MC655JJ0VHvNY8aMGRul/wAAAPDvfLjkmqG8vLxGz5PZ7uu3JZKAfuqpp8YPf/jD2HHHHT/06xcXF6cPAAAAaDGhu0uXLuma7PVHtefPn99k9DuxbNmyGDNmTFpw7bzzzkvbamtr05CejHo//PDDccghh2yy/gMAAMAWO728qKgo3SLskUceadSePN9vv/2anJ8UPRs/fnxaNG3N45xzzomddtop/X1SlA0AAAC2JJt1evnFF18cp512Wuy1116x7777xjXXXJNuF5aE6TXrsWfNmhU33nhj5Ofnx9ChQxtd361bt3R/7/XbAQAAIFp66D755JNj0aJFcdlll8WcOXPS8Dx69Ojo379/ejxp+3d7dgMAAMCWarPu0705JPt0J1uH2acbAACAbXZNNwAAAGzrhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAACA0A0AAABbFyPdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAA2FZD91VXXRUDBw6M1q1bx4gRI+KZZ55p9ty77747Dj/88OjatWuUlJTEvvvuGw899NAm7S8AAABsFaH79ttvjwsvvDAuvfTSePXVV+OAAw6Io446KqZPn57z/KeffjoN3aNHj46xY8fGwQcfHMcdd1x6LQAAAGxp8urq6uo215uPHDkyhg8fHldffXVD25AhQ+KEE06Iyy+//EO9xq677honn3xyfP/73895vKKiIn2sUV5eHn379o2ysrJ0tBwAAAC2uZHuysrKdLR61KhRjdqT588///yHeo3a2tpYtmxZdOrUqdlzkvBeWlra8EgCNwAAAGzToXvhwoVRU1MT3bt3b9SePJ87d+6Heo1f/OIXsWLFijjppJOaPeeSSy5JR7XXPGbMmPEf9x0AAAA+jMLYzPLy8ho9T2a7r9+Wy6233hr/+7//G3//+9+jW7duzZ5XXFycPgAAAKDFhO4uXbpEQUFBk1Ht+fPnNxn9zlWA7ayzzoo77rgjDjvssIx7CgAAAFvZ9PKioqJ0i7BHHnmkUXvyfL/99vvAEe4vfOELccstt8QxxxyzCXoKAAAAW+H08osvvjhOO+202GuvvdI9t6+55pp0u7BzzjmnYT32rFmz4sYbb2wI3Keffnr8+te/jn322adhlLxNmzZpkTQAAADYkmzW0J1s9bVo0aK47LLLYs6cOTF06NB0D+7+/funx5O2dffs/uMf/xjV1dXxta99LX2sccYZZ8Sf//znzfIZAAAAYIvcp3tzSPbpTkbF7dMNAADANrumGwAAALZ1QjcAAAAI3QAAALB1MdINAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJARoRsAAAAyInQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAiIi6qqqomDw5qhcvdj+AjaZw470UAABs3tC86Pobouzee6N21apof+CB0fW8r0Vh167p8RXPP58er5wxPVoP2SW6nP3laL3LLumxpffcG/N/+YuoWbAwoqAgSo44Inr+6LLIb9fOjxT4j+TV1dXVRQtSXl4epaWlUVZWFiUlJZu7OwAAbCSzvvmtKL/vvkZtrfr3i0H33BPLn3k2Zl14YcQ6//TNa906+t98U9StXBnTTj+j0bFEybHHRu8rfu7nA/xHTC8HAGCrV/n++1F+//1N2qumTY+y++6PBb/9TZNQXbd6dSz64zWx5NbbmhxLlD/4YFQvWZJpv4Ft32YP3VdddVUMHDgwWrduHSNGjIhnnnnmA89/6qmn0vOS8wcNGhR/+MMfNllfAQDYMq2eOClncE6Pvf1WVE5+L/exN9+M6oULc79odXXUCN3A1hy6b7/99rjwwgvj0ksvjVdffTUOOOCAOOqoo2L69Ok5z586dWocffTR6XnJ+d/97nfjggsuiLvuumuT9x0AgC1H0YABzR8bNCgKu3XLeaxV377Rdq8ROY8l1xT167fR+gi0TJt1TffIkSNj+PDhcfXVVze0DRkyJE444YS4/PLLm5z/7W9/O/7xj3/EhAkTGtrOOeeceP311+OFF17I+R4VFRXpY9013X379rWmGwBgGzP97LNjxdONZ00WdO0S299/fyy9866Y//Om67P7/OHqaLP77vH+yadE1boDP/n50eunl0fpJz+5KboObMM220h3ZWVljB07NkaNGtWoPXn+/PPP57wmCdbrn3/EEUfEmDFjoqqqKuc1SXhPCqeteSSBGwCAbU+fX/0qOp56auS3b59WIG9/yCHR/y83RkFpaXQ+64vR7ZvfSEN4oqh//+j1859Hh4MOisKOHWPA7bdFl/POi7b77BMlxx0X/W/6q8ANbN1bhi1cuDBqamqie/fujdqT53Pnzs15TdKe6/zq6ur09Xr27NnkmksuuSQuvvjiJiPdAABsW5LtvXp8/3/SRzKZMy8vr9HxzmedFZ3OPDNqV66KgvaNtwJLgneyvRjANrdP9/p/Geb6C/LfnZ+rfY3i4uL0AQBAy9Hcvw3z8vObBG6AbXJ6eZcuXaKgoKDJqPb8+fObjGav0aNHj5znFxYWRufOnTPtLwAAAGw1obuoqCjd+uuRRx5p1J4832+//XJes++++zY5/+GHH4699torWrVqlWl/AQAAYKvaMixZa33ttdfG9ddfn1Ykv+iii9LtwpKK5GvWY59++ukN5yft06ZNS69Lzk+uu+666+Ib3/jGZvwUAAAAsAWu6T755JNj0aJFcdlll8WcOXNi6NChMXr06Ojfv396PGlbd8/ugQMHpseTcP773/8+evXqFb/5zW/ixBNP3IyfAgAAALbAfbo3h6R6ebJ1WFlZWZSUlGzu7gAAALAN26zTywEAAGBbJnQDAABARoRuAAAAyIjQDQAAABkRugEAACAjQjcAAABkROgGAACAjAjdAAAAkBGhGwAAADIidAMAAEBGhG4AAADIiNANAAAAGRG6AQAAICNCNwAAAGRE6AYAAICMCN0AAACQEaEbAAAAMiJ0AwAAQEaEbgAAAMiI0A0AAAAZEboBAAAgI0I3AAAAZEToBgAAgIwI3QAAAJCR/9/emYBbNb1/fN1SaZ4oTRJKpZJUplKZpyiShAjJEEqoVJrJEDI0SMgsDYREIiRCiiRDSJGxOSRq/57P+j/r/PfZZ5/h3u65nXvv9/M857n37L3P3muvvda73mmtLaNbCCGEEEIIIYRIEzK6hRBCCCGEEEKINCGjWwghhBBCCCGESBMyuoUQQgghhBBCiDQho1sIIYQQQgghhEgTMrqFEEIIIYQQQog0IaNbCCGEEEIIIYRIE3uYQobnefbv5s2bd3dRhBBCCCGEEELkc8qWLWuysrLi7i90RveWLVvs31q1au3uogghhBBCCCGEyOds2rTJlCtXLu7+LM+FfgsJO3fuNGvXrk3qjRC5AxkFODjWrFmTsCEKkR9R+xYFGbVvUVBR2xYFGbXv3YMi3QGKFCliatasuXueRiEGg1tGtyioqH2LgozatyioqG2Lgozad2ahhdSEEEIIIYQQQog0IaNbCCGEEEIIIYRIEzK6RVopUaKEGTJkiP0rREFD7VsUZNS+RUFFbVsUZNS+M5NCt5CaEEIIIYQQQgiRVyjSLYQQQgghhBBCyOgWQgghhBBCCCHyF4p0CyGEEEIIIYQQaUJGtxAiY8jKyjIvvPBC3P377befuffee/O0TEIIkWkUJlk5f/58e78bN25M+TcXX3yx6dChQ+R727ZtTe/evU1es2rVKlv2pUuX5vm1hRCZhYzuPCCesGfARBg7HnvsMfu9QYMGMcdOnTrV7mMgDfL333+bihUrmkqVKtn/g/AbfsunVKlSplGjRmbixIkmncQb8IcOHWqaNm0a9d2VbY899jB77bWXOeaYY+xv//nnn5h6dMcWKVLEVK1a1Zxzzjnmhx9+iByzY8cOc9ttt5n69eubkiVL2jo54ogjzKOPPhp1rl9++cVcc801Zv/997erPNaqVcu0b9/ezJs3L6bMt956qylatKgZPXp0zD73zE4++eSo7SgHbEdZEP/Hb7/9Znr27Gn23XdfW+f77LOPOemkk8z7779fIKooTLnasmWLbbe0xzVr1thtrg1/8MEHUb+nvVeuXFntpgCCAeCeu/+zcuXKlOVMhQoVsiXnctLfgvIZ3n33XXtt5CXrruZ0nBKpU1hkJWP+Tz/9FLXv559/ttvZz3Fw1FFH2e3ly5dP+Rpjx461bXV3G/+5pUPR9/r27WvKli1r3nzzzSidKExmnHrqqXYffVoUrHHj2GOPtbryyJEjQ3/H2MD+7du3p3Sdt956y7YX9A9shIYNG9q2FuybYteR0Z1hlC5d2g64wcH1kUcesQNwGNOnT7eGNB1lxowZoccMHz7cDlqfffaZ9f5eccUV5rnnnsvWAJkuDj74YFu21atX286PIY3QYKDFaPHTo0cPeyzC4MUXX7SGzAUXXBDZzwDDQDVixAjzxRdf2PPxmw0bNkTdz2GHHWYHrjvuuMMsW7bMzJkzx7Rr185cffXVMeVDkb3pppvsMwgDBQFjnWuJ+Jx99tnm008/NVOmTDFff/21mTVrllUa1q9fn1HVlupAlYzff//dtqmtW7eaBQsWWMeOg/+DjqCZM2eaMmXK5Mq1ReaBYw7Z5f/UqVMnZTnjJxU5lxv97ZVXXrHG3nXXXWfuv//+yDiQk3FKpE5hkZXVq1c3jz/+eNQ27rlGjRpR24oXL24dD9nRQzDQ/Y6q/AxOtksvvdTWFXoLRleisWTt2rX2uGrVqu2G0op0jxvo/Oi9OJXCXkBFe7jwwgttv0kGAbjjjz/e9i/Oy3gyYcIEs2nTJjNmzBg9zNyGV4aJ9NKmTRvvuuuui9k+c+ZMekvk+6OPPuqVL1/e69Wrl3fZZZdFtq9Zs8YrUaKE179/f6927dox52nbtq03YcIEb/z48V67du1i9vObe+65J2pb3bp1vS5duqRU/u+//z6qnKkQdk0YMmSId8ghh8T97lixYoVXvHhxb+DAgQnr8fHHH/dKlSoV+c65hg4dmrBsp5xyilejRg1v69atMfs2bNgQ9X3+/Pn22O3bt3vVq1f33n777aj97pn16NHDa9myZdR5qLO33norYVkKC64+qM9EcAz9wjFs2DCvSpUq3pIlS0Lb1caNG23d77333l7ZsmVt+1+6dGlk/8qVK70zzjjDnqN06dJe8+bNvblz50Zdk3OOGDHCu+iii7xy5cp53bp1izzXOXPmePXr17e/Pemkk7y1a9cm7SeUdfXq1d5BBx1k++bmzZtj7nHQoEH2Wn/99Vdk+wknnOANHjw4pt38+OOPXufOnb0KFSp4lSpVsvfDtRwffvihd/zxx3uVK1e25zzmmGO8xYsXx1xz0qRJXocOHbySJUt6Bx54oPfiiy9G9q9fv97r2rWrt9dee3l77rmn3f/II48kfFYidWhbZ555Ztz9qcqZVOVcqv0tiF8eP/XUU1YGjx07NrQs2RmnZs2a5TVr1szur1Onji37v//+G9k/ZswYr1GjRlaW16xZ07vyyiu9LVu2xFwzUX+kz7Ro0cKeg2OPOuoob9WqVV5+ozDJSuQguogf5KaTg07O8Wz57sbnVK4Z7HPoD1dffbX98FtkKfrFzp07I8c88cQT3mGHHeaVKVPGq1q1qnfeeed5v/76a1SZ/R+uATt27PBGjx7tHXDAAbbP1KpVyxs5cmTU76ZPn27HA+RvkyZNvIULFyZ8vu75bdu2zevYsaPtF1988UXUMdwTfQXZv2DBgsj2UaNGee3bt7d9mT7t+Oeff7wbb7zRyhj6CTqLf6z5448/rF6ILKKc9Mmnn3465prXXHONPU/FihVtPfmvAXynDqiLatWq2eNF7o4bn332WaiceOedd+z2ZcuW2XaJXOB58ixoD6+++mqUzGZ77969Q68R1IfFrqNIdwaCR5Mo9F9//WW/483C20U6dZBvv/3WRhs6d+5sPwsXLjTfffdd0mvsueee5t9//zWZCmmTp5xyStzIPeD1f/75583hhx8e2Ya3Dg8vUcZ4vyGqTUSbaE2QoGd88uTJ5rzzzjPFihWzf/keL/JExHzatGnZuMvCAxFcPkypCE4bCAOdkuga9U2UOJjy6o457bTT7FSB2bNnm8WLF5tmzZqZ4447LhIRIspM2tQbb7xhlixZYqN2TCMgq8LPnXfeabNFOMfgwYPtNvrfXXfdZZ544gnzzjvv2N/ccMMNScv+1VdfmaOPPtq2Ydoa6YBByLQgyolnGcjY4Bp4p/1QBqLl1B37qQv+Rx64KBPZIBdddJFNAyZlvW7duvaeg1kiw4YNszKCbBf2n3/++ZF64p7xcL/66qtmxYoVZvz48TY9TeQNqcqZVOVcdvtbkAcffNB0797dluPaa6/dpXHqtddes1EZzkMbI7LCsaNGjYocw3Sh++67z3z++ec20sm9EfX3k6g//vfffzaDq02bNrZ9MyZefvnlac3QSheFSVaeccYZNjuDcgN/KQ/XTUZOrknbIjNt0aJFtr3dc8895uGHH47sR6aSPUKWAfX//fff2xRfF1F28hoZT8SRFHYYMGCAuf322yNy9Omnn47pBwMHDrTlY/pRvXr1bD+n3SaCZ8JzW758uXnvvfdCp3QQzUSW+6Pd9K9LLrkk5lj6NOd59tlnbT8hq5A++80339j927Zts2PTyy+/bPsifYgxifoK1iP6E9vJFiSTcu7cuXYfOhD1Sj/nvNRj48aNE96nyD7UaYsWLWKyHMg2atmype2jtE+i1fQTnjd9mj7nnjf6M20+KGsdBSVTJKPIBcNd5HKkG5o2bepNmTLFemHxnhKVwusZjCDcfPPNNnrlwCvmjw4HPd5EF7gO1x03blxaI9140PBA+z/FihVLKdIN/fr1s95Wfz3ye86Dl5Yy1atXLyrqt3z5cq9BgwZekSJFvMaNG3s9e/b0Zs+eHdm/aNEi+7sZM2YkvYdNmzbZ67hoABEEvrM97JkR4aE81LEi3bFMmzbNesaJpBKFGjBggPfpp59GHcOzef75570LLrjARjDwxMZry/PmzbPRFiIBfugvEydOjPtcGzZs6N1///1R5/T3IfdcKQvRH8eDDz5ovfrJ+gntnojGf//9lzBCde+990YyU/BGE80ItpvJkyfbyI8/GkO0gn7x2muvhZ6f6xLJeumll6KuSVTJQZZHVlZWxOtNVKR79+5x703sesSiaNGiUbKwU6dOOZIzqci5VPtbEOQx7Zf2QtsLI7vjVOvWrb1bb7016hxEFImAxWPq1Kk2epdqf1y3bl2OIvuZSmGRlbR1omxO9vC3T58+dnuySHeya4ZFuukzflmKjsG2eJBFxHVc1kWwHEAmExkcZBIluteHH344qv+yjYy+ZDoU/cBF2+PplrQNZD5ynSwZshXImvFHuqkrZP5PP/0UdY7jjjvOtq94nHrqqV7fvn2jrtmqVauoY8gwoS5d1gp6ENcXuT9u8Bk+fLjdT3Yr31375C/fXZ8mo4Gsh+Czuuqqq+z/ZEkgF0TeoUh3hoKXEg/W22+/HfFAh83zwePon9PM/2xjn59+/fpZ7zmL7hDlvfHGG+1CLYnmWTuPO/+D++7flgiugVfX/2EueaqgVwQjFXh0OQ+eaLziBx54oDnxxBMjUT3mteOhJeKHV/fXX3+1XvPLLrssck5IJQKCt5qF1g455BD7nQgC3/ESh0EdE3lKZU5mYYR5isw1Y34iHlcWpSHaElzspk+fPjZSReS2Zs2acc9HpIW+weIf/rZJdIIMEPjzzz+tF5d2gdeW/V9++WVM9KZ58+Yx52dBkQMOOCDynflxzGNNxplnnmnbpouKxIO+yn2SmRIvMsE9stgW0XJ3fyyaRUTC3SNlol8RPWEeIx/qJXiPTZo0ifxPlIJzuvu58sorbbumjVNfZMyI3IWMBb8sJNKWEzmTipzLTn8LQp/jOCJYRPN2dZyiDRMJ8/dRtzaHi5IzJ/2EE06wc3lpl926dTPr1q2z/TeV/kifICLporNEeJKVPZMpLLLSZUwQcSMKz98wORhGTq7JYoP+sf/II4+0UT+nLxHhR37Xrl3btkPm0UOwDvyQGURGAlkDifDLXzfXOll50W14LiyymOzcZDgRZUb/IDpN1oyfTz75xOo/jBP+NkDfdW2AeiADhfO5tvL6668nHEvc/bh7IXrOgr7IMPo5a5Uki+iL1McNPm7tIbIldu7cGVmfib884y5dupjNmzdbGULWnR++02bj6dgiveyR5vMLY0y5cuXsogRBWAGTfWFgXDIAkraMAkJKVBDS9lhQ7Nxzz43ajuBEUJKe7TeAUUoYqBCQyToaKWgu/ZxrMPj4V2UOCvQwSE/FKPaDcpQqCAb/QkOAQeHOyV9S6rgfhI1TOElVJO2GD0rJk08+aQch0rsYmLh3zu1/nUgYDF6kdfnrHgHHNUm7CoKiQpoZabynn356yvdZmGBaA8o1n1tuucU+syFDhkRS+IB9zzzzjG3f9IN48Cx49mErxLu0KNo95yG9ivaC06lTp04xCwCFTTUItnHaTdiiJUFuvvlmq5RQdo4P9k8HSg3tBKUTI5r+GkwJ5x5J93vqqadifr/33nvbv9Qdzh4W1kJZZLVjlMngPYbdD+cHrs1bAFg4i/RSFEgGdupN5A60saA8zImccSSSc05uptLfgmBs0AZQ+JH7GMQseJXTcYp7QSaeddZZMfsoH+0OYx3HEam9jBE4regX/ilQyfojxj8p7EzpYDwYNGiQTXnF0MqPFAZZCaTBMhUHA4L0ab6n8nqtXblmGBi3tHk+9CXkK8YmTo9EC8ZRT6ngL6/Tv5z8jQdymDaNIwC9jsUM44GzgmkhpLd/+OGHMfu5Fm9HwAHDXz9uAU9SkUkNZywhfZlnzZt3sjOWkIZP+j19Dzly1VVX2SkJGPep6I0itXHD6cP0UWQf8pK/fMeuwOh2z8aP39DGAYNtgoNSi+7lDYp05wEMKB9//HHM9o8++sgcdNBBob9B8WDuBYIqnucXpQyPVtALxuAbnBPoDGCUp1Q8WyjvHM+H/8F9929LF3jYUZ7w+CfCDR5hr0pz4Ll3gyr1yiDK4OSPojjcq0CYn80zQ0nx1y1zx3huRJnC4LU6KMNurpdIDM8m+Bxo90T/UDITRfuI/BAdQdH3t00+bj4yESCU1I4dO1olgrmw7jU06QSFHwOCvohSHA/6Nm0MgyWoCLl7JBJTpUqVmHt0r8/hHlHMMFzIQMHo/uOPP7JdZpRM6gqFE6XroYceyvY5RPbIqZxJJucSHZNov4NXUKIw8xfDO96rY1IZp2jDKOHB9ssHWcn9EwlD4cdARhEkQpMTDj30UOv4JFMD4w05UlAoqLLSLwdTjXLnlOBrGt0aGMhedA7kJq/fat26tdXbgpFotxq0P5OQ32N4h71uNDfAscIca5xzOELjORa6du1q5Yl7k01Y36Dc3FOwDfCsXRvAwCcLi8wbotVu/m92oD5om2Tz8FzJxqBsIvfB2GaePm2Ev3wHDG/0fbdeggPZ6NYGwECnTZPVFEZevBqvsKFIdx6Ap++BBx6wApPIBQIJLyCGMYuAxINUsnHjxtmIWBAiWy+99JJNP0PI+mFRJRbf4BgXDctkULhQCPCUklKIkOb9g6RZ4n33QzoixwIplRxHRADvtBMipM/wujEGEtLnUMJQ5BhEgTplP4tNkPZIVJIy8ExYQIooOM+G/bwzPAhRRPbjEQ5CWYjqhL16rDDDcyXtDKWK+iaahrKNsGeQD4LiR98gcoeiyHMNwmsueBZkLLCIDQ4slHWyNNhGGiQKBYvxkXKKs4mFbpJFF3KL/v37W2WOe+CaYZEoFrGhnybKeCFKQB3RVkkhJfrCPdE3+M49UlfcL95ttqcafXEQSSOijtFOqiQDeNiiPSJ3yamcSSbnstvfwsCpQ8YUbdRFvMNSmBONU65tkdFBBIwyYWizqA9KOPKbFGHkL1E8+imKI6+syQ7cP04iFH0UTYx8XrWFMyu/URhlJWnI3HO6F25iwcrrr7/eTq0j3Zo2516LxKvuMEDYRtYFDi8cp34INlA3yEecnMhZosRMLSPjg9/TL5HpZK84A2hX4RVhZCHRjzC6CRoEgyc4yIhYxosmIxsYT+gT3DNGOE4GFi3EycL90AaYFoVhxvnuvvtuq29lZyxAHmDcs8AtmZW0Teop3YGaggrjsdN5HfRz5yxj8UieG8+Vv/6xBF2A7BhkLPo0kXCcui5zDpnM+NKrVy+rO3AO3g//448/2tfT0bb12rDcRZHuPIBGjAeReTMYh6QDIpj4MNDEA0EVT5GhQ5B2EjaPiDkgDNSJDPpMgsGJ1BYGPZS7qVOnWgWSOgu+t3jSpEn2WD7cJ4MbioPLGCCKjTMCxYFBBgcESijKo0t9JPWSAZff9+3b1zot8CbjqcboJpWKaF+8KDvb2R8v5Yxr4iEW/w/PkUEYAc+gQJ2j1KFs4ZAKA+WR9QlQJsNWsUfp4NlzPhRUnjeZH0Rn3MqxXA/lAeOENkH7IOqTVzDooSzTJsL6I/fA4BnvfZooLUQ96Ruk56L8cK9kdjhDnQgIKwCjRFFXRL2JjGcHrk+fQ8mnPnEWJIqciV1nV+RMMjmXk/4WBm2MlGP6E7IZoyU745QrK0YKTk3GPqLZKPNOCUcZ5DvGIOVEIbzttttMdqCfEKmkzqgPnNsokonWLclUCqOsdEZE2PSE3ASjAtmJowvHOJlpbgoHAQp0MuaVEykm4h2cXsOaAzjVcahSb7Qx4PmgS+BgQkYzpSjVOe2pQv/jGTKOsAZHWMQbp0VY+r8Do4s6oKzoTDipWIEc48vdB8+cZ8/1cOglm4YXVgb0NJwPjCfoVciqRDJCxIeMT6fzuk+rVq2ijqFPowMEM0XQBXjWfHCscC4CdWRn+IOCjBtkM+HAYxwhcwbZn8obCET2yGI1tWz+RgghhBBCCCGEECmgSLcQQgghhBBCCJEmZHQLIYQQQgghhBBpQka3EEIIIYQQQgiRJmR0CyGEEEIIIYQQaUJGtxBCCCGEEEIIkSZkdAshhBBCCCGEEGlCRrcQQgghhBBCCJEmZHQLIYQQQgghhBBpQka3EEIIkQ9ZtWqVycrKMkuXLs2Ya7Vt29b07t077eURQggh8hMyuoUQQgiRkFq1apmff/7ZNGrUyH6fP3++NcI3btyomhNCCCGSsEeyA4QQQghReNm+fbspXry42WeffXZ3UYQQQoh8iSLdQgghRIYyZ84c06pVK1OhQgVTuXJlc/rpp5tvv/027vGzZs0ydevWNSVLljTt2rUzU6ZMiYlIT58+3Rx88MGmRIkSZr/99jNjxoyJOgfbRo4caS6++GJTvnx506NHj6j0cv7n3FCxYkW7nWMdO3fuNDfddJOpVKmSNdSHDh0adX6Onzhxor2XUqVKmQYNGpj333/frFy50qanly5d2hx55JEJ71MIIYTIT8joFkIIITKUP//801x//fXmo48+MvPmzTNFihQxHTt2tIZtEIzhTp06mQ4dOljjuGfPnmbgwIFRxyxevNh07tzZdOnSxSxbtswaxIMHDzaPPfZY1HF33nmnTSXnePYHU80x3OGrr76yaedjx46N7MfQx3BetGiRueOOO8zw4cPN3Llzo84xYsQI061bN1vO+vXrm65du9ryDhgwwHz88cf2mF69euVCDQohhBC7nyzP87zdXQghhBBCJOf33383VapUsQZzmTJlTJ06dcySJUtM06ZNTf/+/c0rr7xi9zkGDRpkRo0aZTZs2GCj5eeff749x+uvvx45hqg0v1u+fHkk0n3ooYeamTNnRhn0/msxp5totzuvg0j1jh07zLvvvhvZ1rJlS3Psscea0aNHRyLdlAvDGz744AMb2Z48ebK55JJL7LZnn33WdO/e3fz9999qFkIIIfI9inQLIYQQGQop1kSB999/f1OuXDlr+MLq1atjjiXq3KJFi6htGLx+VqxYYY4++uiobXz/5ptvrLHsaN68eY7L3KRJk6jv1apVM7/99lvcY6pWrWr/Nm7cOGrbtm3bzObNm3NcDiGEECJT0EJqQgghRIbSvn17m849adIkU716dZtWTto3i5sFIXGNKHJwW3aPAdLDc0qxYsWivnO9YDq8/xhXnrBtYWn0QgghRH5DRrcQQgiRgaxbt85Gpll0rHXr1nbbggUL4h7P3OjZs2dHbXPzox0NGzaMOcfChQtNvXr1TNGiRVMuG6uZgz86LoQQQohwlF4uhBBCZCCsDM6K5Q899JBd2fvNN9+0i6rFg4XIvvzyS9OvXz/z9ddfm6lTp0YWSHOR4759+9oF2ZhPzTEsevbAAw+YG264IVtlq127tj3nyy+/bOeIb926dRfvVgghhCi4yOgWQgghMhBWKmdBMVYQJ6W8T58+dlXxeDDfe9q0aWbGjBl2zvT48eMjq5fzejBo1qyZNcY5L+e85ZZb7Ori/ld+pUKNGjXMsGHD7OJtzL/WSuNCCCFEfLR6uRBCCFFAYeXyCRMmmDVr1uzuogghhBCFFs3pFkIIIQoI48aNsyuYk5b+3nvv2ci4otBCCCHE7kVGtxBCCFFA4NVfI0eONOvXrzf77ruvncM9YMCA3V0sIYQQolCj9HIhhBBCCCGEECJNaCE1IYQQQgghhBAiTcjoFkIIIYQQQggh0oSMbiGEEEIIIYQQIk3I6BZCCCGEEEIIIdKEjG4hhBBCCCGEECJNyOgWQgghhBBCCCHShIxuIYQQQgghhBAiTcjoFkIIIYQQQgghTHr4H/qlpbXNrxisAAAAAElFTkSuQmCC",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"sns.catplot(data=scaling_df, x=\"algorithm\", y=\"ari\", hue=\"algorithm\", kind=\"swarm\", height=10)"
]
},
{
"cell_type": "markdown",
"id": "7d27c69d-22bd-4b3c-9e58-defc84d1bdc0",
"metadata": {},
"source": [
"The results are pretty clear. The UMAP + HDBSCAN approach mostly produces good clusters -- the decay in quality is essentially down to having run with default parameters which need to be adjusted somewhat for the larger dataset sizes (we weren't doing any parameter tuning). KMeans mostly manages to find the right clusters. FAISS's Kmeans was possibly not well tuned. MiniBatchKMeans shows a lot more variability than KMeans (as we would expect) including some very poor results occasionally; it might be worth it if you need the speed, but it definitely has costs in terms of cluster quality. Lstly, however, we have EVoC which, despite keeping pace with MiniBatchKMeans in compute time the whole way, also manages to produce almost perfect clusterings every time."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "evoc_docs",
"language": "python",
"name": "evoc_docs"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
================================================
FILE: doc/source/changelog.rst
================================================
Changelog
=========
This document records all notable changes to EVoC.
Version 0.1.0 (TBD)
-------------------
Initial release of EVoC.
**Features:**
* Core clustering algorithm with hierarchical multi-layer support
* Scikit-learn compatible API
* Support for multiple embedding types (float, int8, uint8)
* Optimized distance metrics (cosine, quantized cosine, bitwise Jaccard)
* Numba-accelerated performance
* Comprehensive parameter set for fine-tuning
* Built-in duplicate detection
* Extensive documentation and examples
**API Reference:**
* ``EVoC`` - Main clustering class
* ``evoc_clusters`` - Functional interface
* ``build_cluster_layers`` - Multi-layer clustering construction
**Performance:**
* Efficient processing of high-dimensional embeddings
* Memory-optimized algorithms
* Multi-threaded computation support
**Documentation:**
* Complete API documentation with numpydoc formatting
* Interactive Jupyter notebook examples
* Comprehensive user guide and tutorials
* ReadTheDocs integration
================================================
FILE: doc/source/conf.py
================================================
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import sys
sys.path.insert(0, os.path.abspath("../.."))
project = "EVoC"
copyright = "2024, Tutte Institute"
author = "Tutte Institute"
release = "0.1.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx.ext.intersphinx",
"sphinx.ext.mathjax",
"sphinx.ext.githubpages",
"numpydoc",
"nbsphinx",
"IPython.sphinxext.ipython_console_highlighting",
]
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"]
# -- Options for HTML output ------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
html_css_files = ["custom.css"]
# Theme options
html_theme_options = {
"canonical_url": "",
"analytics_id": "",
"logo_only": False,
"display_version": True,
"prev_next_buttons_location": "bottom",
"style_external_links": False,
"vcs_pageview_mode": "",
"style_nav_header_background": "#2980B9",
# Toc options
"collapse_navigation": True,
"sticky_navigation": True,
"navigation_depth": 4,
"includehidden": True,
"titles_only": False,
}
# -- Extension configuration -------------------------------------------------
# Autodoc configuration
autodoc_default_options = {
"members": True,
"member-order": "bysource",
"special-members": "__init__",
"undoc-members": True,
"exclude-members": "__weakref__",
}
# Autosummary configuration
autosummary_generate = True
autosummary_imported_members = True
# Napoleon configuration (for Google and NumPy style docstrings)
napoleon_google_docstring = True
napoleon_numpy_docstring = True
napoleon_include_init_with_doc = False
napoleon_include_private_with_doc = False
napoleon_include_special_with_doc = True
napoleon_use_admonition_for_examples = False
napoleon_use_admonition_for_notes = False
napoleon_use_admonition_for_references = False
napoleon_use_ivar = False
napoleon_use_param = True
napoleon_use_rtype = True
napoleon_preprocess_types = False
napoleon_type_aliases = None
napoleon_attr_annotations = True
# Numpydoc configuration
numpydoc_show_class_members = False
numpydoc_show_inherited_class_members = False
numpydoc_class_members_toctree = False
numpydoc_use_plots = True
numpydoc_validation_checks = {
"all",
"GL01",
"GL02",
"GL03",
"GL05",
"GL06",
"GL07",
"GL09",
"GL10",
}
# NBSphinx configuration
nbsphinx_execute = "never" # Don't execute notebooks during build
nbsphinx_allow_errors = True
nbsphinx_timeout = 60
nbsphinx_codecell_lexer = "ipython3"
# Intersphinx configuration
intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"scipy": ("https://docs.scipy.org/doc/scipy/", None),
"matplotlib": ("https://matplotlib.org/stable/", None),
"sklearn": ("https://scikit-learn.org/stable/", None),
"pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
}
# Math configuration
mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
# Source file suffixes
source_suffix = ".rst"
# Master document
master_doc = "index"
# Language for content autogenerated by Sphinx
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for LaTeX output -----------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, "EVoC.tex", "EVoC Documentation", "Tutte Institute", "manual"),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [(master_doc, "evoc", "EVoC Documentation", [author], 1)]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
"EVoC",
"EVoC Documentation",
author,
"EVoC",
"Embedding Vector Oriented Clustering",
"Miscellaneous",
),
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
epub_author = author
epub_publisher = author
epub_copyright = copyright
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ["search.html"]
================================================
FILE: doc/source/examples.rst
================================================
Examples
========
Collection of practical examples demonstrating EVoC usage in different scenarios.
Basic Examples
--------------
Simple Clustering
~~~~~~~~~~~~~~~~~
.. code-block:: python
from evoc import EVoC
import numpy as np
# Simple example with random data
X = np.random.rand(500, 128)
clusterer = EVoC()
labels = clusterer.fit_predict(X)
print(f"Found {len(np.unique(labels[labels >= 0]))} clusters")
Specify Number of Clusters
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# When you know the desired number of clusters
clusterer = EVoC(approx_n_clusters=5)
labels = clusterer.fit_predict(X)
Working with Real Embeddings
-----------------------------
CLIP Embeddings
~~~~~~~~~~~~~~~
.. code-block:: python
import torch
import clip
from evoc import EVoC
# Load CLIP model
device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-B/32", device=device)
# Generate embeddings for images
# (assuming you have a list of PIL images)
embeddings = []
with torch.no_grad():
for image in images:
image_input = preprocess(image).unsqueeze(0).to(device)
embedding = model.encode_image(image_input)
embeddings.append(embedding.cpu().numpy())
X = np.vstack(embeddings)
# Cluster the embeddings
clusterer = EVoC(
n_neighbors=20,
noise_level=0.6,
base_min_cluster_size=3
)
labels = clusterer.fit_predict(X)
Sentence Embeddings
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from sentence_transformers import SentenceTransformer
from evoc import EVoC
# Load sentence transformer model
model = SentenceTransformer('all-MiniLM-L6-v2')
# Your text data
texts = [
"The cat sat on the mat",
"Dogs are great pets",
"Machine learning is fascinating",
# ... more texts
]
# Generate embeddings
embeddings = model.encode(texts)
# Cluster similar texts
clusterer = EVoC(
n_neighbors=15,
noise_level=0.4,
base_min_cluster_size=2
)
labels = clusterer.fit_predict(embeddings)
# Group texts by cluster
clusters = {}
for i, label in enumerate(labels):
if label >= 0: # Ignore noise points
if label not in clusters:
clusters[label] = []
clusters[label].append(texts[i])
Advanced Usage
--------------
Hierarchical Analysis
~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Get multiple clustering granularities
clusterer = EVoC(max_layers=5)
clusterer.fit(X)
# Analyze each layer
for i, layer in enumerate(clusterer.cluster_layers_):
n_clusters = len(np.unique(layer[layer >= 0]))
persistence = clusterer.persistence_scores_[i]
print(f"Layer {i}: {n_clusters} clusters, "
f"persistence: {persistence:.3f}")
# Access the hierarchical structure
tree = clusterer.cluster_tree_
print(f"Hierarchical structure: {tree}")
Parameter Optimization
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from sklearn.metrics import silhouette_score
# Grid search over parameters
best_score = -1
best_params = None
for n_neighbors in [10, 15, 20]:
for noise_level in [0.3, 0.5, 0.7]:
clusterer = EVoC(
n_neighbors=n_neighbors,
noise_level=noise_level,
random_state=42
)
labels = clusterer.fit_predict(X)
if len(np.unique(labels[labels >= 0])) > 1:
score = silhouette_score(X, labels)
if score > best_score:
best_score = score
best_params = {
'n_neighbors': n_neighbors,
'noise_level': noise_level
}
print(f"Best parameters: {best_params}")
print(f"Best silhouette score: {best_score:.3f}")
Memory-Efficient Processing
~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# For large datasets, use smaller parameters
clusterer = EVoC(
n_neighbors=10, # Reduce graph density
node_embedding_dim=8, # Lower embedding dimension
n_epochs=30, # Fewer training epochs
max_layers=3 # Limit hierarchy depth
)
# Process in chunks if needed
chunk_size = 10000
all_labels = []
for i in range(0, len(X), chunk_size):
chunk = X[i:i+chunk_size]
chunk_labels = clusterer.fit_predict(chunk)
all_labels.extend(chunk_labels)
Specialized Data Types
----------------------
Binary Embeddings
~~~~~~~~~~~~~~~~~~
.. code-block:: python
# For binary/hash embeddings
binary_embeddings = (embeddings > 0.5).astype(np.uint8)
clusterer = EVoC(
n_neighbors=25, # More neighbors for binary data
neighbor_scale=1.5, # Denser graph
noise_level=0.4 # Lower noise threshold
)
labels = clusterer.fit_predict(binary_embeddings)
Quantized Embeddings
~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# For int8 quantized embeddings
quantized_embeddings = (embeddings * 127).clip(-127, 127).astype(np.int8)
clusterer = EVoC(
n_neighbors=20,
base_min_cluster_size=8,
noise_level=0.6
)
labels = clusterer.fit_predict(quantized_embeddings)
Evaluation and Validation
--------------------------
Cluster Quality Assessment
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from sklearn.metrics import (
silhouette_score,
calinski_harabasz_score,
davies_bouldin_score
)
# Fit the clusterer
labels = clusterer.fit_predict(X)
# Calculate quality metrics
if len(np.unique(labels[labels >= 0])) > 1:
silhouette = silhouette_score(X, labels)
calinski_harabasz = calinski_harabasz_score(X, labels)
davies_bouldin = davies_bouldin_score(X, labels)
print(f"Silhouette Score: {silhouette:.3f}")
print(f"Calinski-Harabasz Score: {calinski_harabasz:.3f}")
print(f"Davies-Bouldin Score: {davies_bouldin:.3f}")
# Analyze membership strengths
strengths = clusterer.membership_strengths_
print(f"Average membership strength: {np.mean(strengths):.3f}")
print(f"Std of membership strengths: {np.std(strengths):.3f}")
Stability Analysis
~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Test clustering stability across random seeds
stability_scores = []
for seed in range(10):
clusterer = EVoC(random_state=seed)
labels = clusterer.fit_predict(X)
if len(np.unique(labels[labels >= 0])) > 1:
score = silhouette_score(X, labels)
stability_scores.append(score)
print(f"Mean stability: {np.mean(stability_scores):.3f}")
print(f"Std stability: {np.std(stability_scores):.3f}")
================================================
FILE: doc/source/index.rst
================================================
.. image:: evoc_logo_horizontal.png
:width: 600
:align: center
:alt: EVōC Logo
EVōC: Embedding Vector Oriented Clustering
==========================================
.. image:: https://img.shields.io/badge/python-3.8%2B-blue.svg
:target: https://www.python.org/downloads/
:alt: Python Version
.. image:: https://img.shields.io/badge/license-BSD-green.svg
:target: https://opensource.org/licenses/BSD-3-Clause
:alt: License
EVōC (pronounced as "evoke") provides Embedding Vector Oriented Clustering.
EVōC (Embedding Vector Oriented Clustering) is a powerful clustering algorithm designed specifically for high-dimensional
embedding vectors such as CLIP-vectors, sentence-transformers output, and other dense vector representations.
The algorithm combines a node embedding approach (related to UMAP) with density-based clustering (related to HDBSCAN),
providing improved efficiency and quality for clustering high-dimensional embedding vectors.
Key Features
------------
* **Optimized for High-Dimensional Embeddings**: Specifically designed for modern embedding vectors
* **Multi-Layer Clustering**: Provides hierarchical clustering with multiple granularity levels
* **Performance Optimized**: Uses Numba for high-performance computation
* **Flexible Parameters**: Extensive parameter set for fine-tuning clustering behavior
* **Scikit-learn Compatible**: Follows scikit-learn API conventions
Quick Start
-----------
.. code-block:: python
from evoc import EVoC
import numpy as np
# Generate sample data
X = np.random.rand(1000, 512) # 1000 samples, 512-dimensional embeddings
# Initialize and fit the clusterer
clusterer = EVoC()
labels = clusterer.fit_predict(X)
# Access cluster layers and membership strengths
print(f"Number of clusters: {len(np.unique(labels[labels >= 0]))}")
print(f"Number of cluster layers: {len(clusterer.cluster_layers_)}")
.. toctree::
:maxdepth: 2
:caption: Contents:
installation
quickstart
user_guide
benchmarks
api/index
changelog
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
================================================
FILE: doc/source/installation.rst
================================================
Installation
============
Requirements
------------
EVoC requires Python 3.8 or later and the following dependencies:
* numpy >= 1.21.0
* scipy >= 1.7.0
* scikit-learn >= 1.0.0
* numba >= 0.56.0
Install from PyPI
-----------------
.. code-block:: bash
pip install evoc
Install from Source
-------------------
To install the latest development version:
.. code-block:: bash
git clone https://github.com/TutteInstitute/evoc.git
cd evoc
pip install -e .
Development Installation
------------------------
For development, install with additional dependencies:
.. code-block:: bash
git clone https://github.com/TutteInstitute/evoc.git
cd evoc
pip install -e ".[dev,docs,test]"
Verify Installation
-------------------
To verify that EVoC is installed correctly:
.. code-block:: python
import evoc
print(evoc.__version__)
# Run a quick test
from evoc import EVoC
import numpy as np
X = np.random.rand(100, 10)
clusterer = EVoC()
labels = clusterer.fit_predict(X)
print(f"Clustering completed successfully! Found {len(np.unique(labels[labels >= 0]))} clusters.")
Note that on first import and first run there will be time spent on Numba's JIT compilation, which may take a few seconds.
Subsequent runs will be much faster, and the compilation should be cached, so it should not need to be repeated unless you
change the code or update Numba.
================================================
FILE: doc/source/quickstart.rst
================================================
Quick Start Guide
================
This guide provides a quick introduction to using EVōC for clustering high-dimensional embedding vectors. EVōC
specifically targets modern embedding vectors such as those produced by CLIP, sentence-transformers, and other
dense vector representations. It seeks to provide fast and effective results with as little parameter tuning as possible.
Basic Usage
-----------
The simplest way to use EVōC is to import the EVoC class, create an instance with default parameters, and call fit_predict on your data:
.. code-block:: python
from evoc import EVoC
from sklearn.datasets import make_blobs
import numpy as np
# Generate sample embedding data
blob_data, blob_labels = make_blobs(n_samples=10_000, n_features=512, centers=256)
# Create and fit the clusterer
clusterer = EVoC()
labels = clusterer.fit_predict(blob_data)
# Analyze results
n_clusters = len(np.unique(labels[labels >= 0]))
n_noise = np.sum(labels == -1)
print(f"Found {n_clusters} clusters")
print(f"Noise points: {n_noise}")
EVōC uses the sklearn API, so you can drop it in to any existing clustering workflow that expects a fit_predict method.
The default parameters are designed to work well for typical embedding data, but you can adjust them as needed
(see the Parameter Selection section below).
Understanding the Output
------------------------
EVōC uses standard sklearn conventions for its output. After fitting, the clusterer will have the following attributes:
* **labels_**: Cluster labels for each point (-1 for noise)
* **membership_strengths_**: Confidence scores for cluster membership
* **cluster_layers_**: Multiple clustering granularities
* **cluster_tree_**: Hierarchical structure of clusters
The ``labels_`` attribute is the expected vector of cluster assignments you would get from any sklearn clustering algorithm.
The ``membership_strengths_`` attribute provides additional information about how strongly each point belongs to its assigned
cluster, which can be useful for filtering or analyzing borderline cases; the is equivalent to the ``probabilities_`` attribute
in HDBSCAN.
The ``cluster_layers_`` and ``cluster_tree_`` attributes are more novel. EVōC is not a hierarchical clustering algorithm in the
traditional sense, instead it produces multiple layers of clustering resolution, that can be results that can be cast into a
hierarchy.
.. code-block:: python
# Access different clustering layers
print(f"Available layers: {len(clusterer.cluster_layers_)}")
# Get membership strengths
strengths = clusterer.membership_strengths_
print(f"Average membership strength: {np.mean(strengths):.3f}")
# Access the cluster hierarchy
tree = clusterer.cluster_tree_
print(f"Hierarchical structure: {tree}")
Layers are sorted from most fine-grained (many small clusters) at index 0 to most coarse-grained (fewer large clusters).
Each layer is a label vector, just like ``labels_``, but with a different clustering resolution. The ``labels_`` attribute
corresponds to the layer that has clusters persisting across the widest range of cluster resolution scales, and is usually
the most stable and meaningful clustering result. However, depending on your needs, other cluster layers may be more appropriate.
The ``cluster_tree_`` attribute provides a hierarchical structure of the clusters across layers.
It shows how clusters in finer layers relate to clusters in coarser layers, effectively creating a tree of cluster relationships.
This can be useful for understanding the multi-scale structure of your data and for selecting clusters at
different levels of granularity.
The tree is structured as a dictionary. Each cluster is identified as a tuple of (layer_index, cluster_id),
and the value is a list of child clusters in the more fine-grained layers.
Parameter Selection
-------------------
Key parameters to adjust:
**n_neighbors** (default=15)
Number of neighbors for graph construction. Increase for more global connectivity.
**base_min_cluster_size** (default=5)
Minimum cluster size at the base layer.
**approx_n_clusters** (default=None)
Target number of clusters (returns single layer if specified).
.. code-block:: python
# Example with custom parameters
clusterer = EVoC(
n_neighbors=25, # More neighbors for denser graphs
base_min_cluster_size=10, # Larger minimum clusters
max_layers=5 # Limit hierarchy depth
)
labels = clusterer.fit_predict(blob_data)
Working with Different Data Types
---------------------------------
EVoC automatically detects data types and uses appropriate distance metrics:
* **float32/float64**: Cosine distance (default for embeddings)
* **int8**: Quantized cosine distance
* **uint8**: Bitwise Jaccard distance (for binary embeddings)
We can take out blob data and convert it to different formats to see how EVoC handles them.
In practice, you would typically be working with actual embedding data that comes
pre-quantized or binarized depending on the model and/or storage format you are using.
embeddings = normalize(blob_data) # Example embedding data
# For standard embeddings (float)
X_float = embeddings.astype(np.float32)
labels_cosine = EVoC().fit_predict(X_float)
# For quantized embeddings (int8)
X_quantized = (StandardScaler().fit_transform(embeddings) * 127).astype(np.int8)
labels_quantized = EVoC().fit_predict(X_quantized)
# For binary embeddings (packed uint8)
X_binary = np.packbits(embeddings > 0.0, axis=1)
labels_binary = EVoC().fit_predict(X_binary)
Next Steps
----------
* See the :doc:`user_guide` for detailed parameter explanations
* Refer to :doc:`api/index` for complete API documentation
================================================
FILE: doc/source/user_guide.rst
================================================
User Guide
==========
This end-user oriented guide covers EVoC's features, parameters, and best practices for different use cases. To better
understand the parameters that are available, it help help to bgin with an overview of the algorithm and its key
components.
Algorithm Overview
------------------
EVoC (Embedding Vector Oriented Clustering) combines two key techniques:
1. **Graph Embedding**: Constructs a k-nearest neighbor graph and learns a lower-dimensional embedding (similar to UMAP)
2. **Density Clustering**: Applies hierarchical density-based clustering to the embedding (similar to HDBSCAN and PLSCAN)
The advantage of EVoC is that it can optimize every part of these tasks for the specific task of clustering high-dimensional
embedding vectors, providing both improved **performance** and **quality** compared to general-purpose clustering algorithms.
That is to say, EVoC not only runs much faster than a combination of UMAP and HDBSCAN, but also produces better clusters as
a result.
The combination of dimension reduction/manifold learning and density clustering tailored to embedding vectors provides several
advantages for clustering embedding vectors:
* Efficient processing of dense, high-dimensional data
* Multiple clustering granularities through hierarchical layers
* Robust handling of noise and outliers
* Optimized distance metrics for different embedding types
Parameter Reference
-------------------
With that core idea -- a two part algorithm -- in mind, let's explore the key parameters that control EVoC's behavior.
The parameters can be broadly categorized into three groups:
Core Parameters
~~~~~~~~~~~~~~~
These are the main parameters that most users will want to adjust based on their specific dataset and clustering goals:
**base_min_cluster_size** : int, default=5
Minimum number of points required to form a cluster at the base (finest) granularity level.
Larger values produce fewer, more stable clusters.
**n_neighbors** : int, default=15
Number of neighbors used in k-NN graph construction. More neighbors capture more global structure
but increase computational cost.
**min_samples** : int, default=5
Minimum samples for density estimation in the final clustering step. Should typically match
or be smaller than base_min_cluster_size.
Clustering Control
~~~~~~~~~~~~~~~~~~
These parameters control the clustering behavior and granularity:
**base_n_clusters** : int, optional
Target number of clusters for the base layer. When specified, EVoC will search for the clustering
granularity that produces approximately this many clusters, then build additional layers on top.
**approx_n_clusters** : int, optional
Target number of clusters for the final output. When specified, EVoC returns only a single
clustering layer (no hierarchy) with approximately this many clusters.
**max_layers** : int, default=10
Maximum number of hierarchical clustering layers to generate. More layers provide finer control
over clustering granularity but increase computation time.
**min_similarity_threshold** : float, default=0.2
Minimum Jaccard similarity threshold for layer selection. Prevents nearly identical clustering
layers in the hierarchy.
Advanced Parameters
~~~~~~~~~~~~~~~~~~~
These parameters provide more fine-grained control over the algorithm and are typically only adjusted by advanced users:
**noise_level** : float, default=0.5
Controls the noise threshold for cluster membership. Higher values produce more noise points
and fewer clusters, while lower values produce more clusters and fewer noise points. In practice
this only provides fine-tuning over the amount of noise, and is not as important as
base_min_cluster_size and min_samples.
**node_embedding_dim** : int, optional
Dimensionality of the intermediate node embedding. If None, defaults to min(max(n_neighbors // 4, 4), 15).
Higher dimensions can capture more complex structure but increase computation.
**neighbor_scale** : float, default=1.0
Scales the effective number of neighbors (neighbor_scale × n_neighbors). Values > 1.0 create
denser graphs, values < 1.0 create sparser graphs focused on local structure.
**n_epochs** : int, default=50
Number of optimization epochs for the node embedding. More epochs improve embedding quality
but increase computation time.
**node_embedding_init** : {'label_prop', None}, default='label_prop'
Initialization method for the node embedding. 'label_prop' uses label propagation for initialization,
None uses random initialization.
**n_label_prop_iter** : int, default=20
Number of label propagation iterations when using 'label_prop' initialization.
**symmetrize_graph** : bool, default=True
Whether to make the k-NN graph symmetric. Recommended for most use cases.
**random_state** : int, optional
Random seed for reproducible results. When specified, enables deterministic mode.
Best Practices
--------------
As a general rule EVoC is desgined to largely be as parameter-free as possible. The default parameters
should work well for a wide range of datasets and use cases, and most users will not need to adjust them.
So the best place to start is just running with default parameters and then adjusting based on the results.
However, here are some best practices for different scenarios:
Working with Hierarchical Output
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
EVoC provides multiple clustering layers with different granularities:
.. code-block:: python
clusterer = EVoC(max_layers=5)
clusterer.fit(X)
# Explore different granularities
for i, layer in enumerate(clusterer.cluster_layers_):
n_clusters = len(np.unique(layer[layer >= 0]))
n_noise = np.sum(layer == -1)
persistence = clusterer.persistence_scores_[i]
print(f"Layer {i}: {n_clusters} clusters, {n_noise} noise points, "
f"persistence: {persistence:.3f}")
# Use cluster tree for hierarchical analysis
tree = clusterer.cluster_tree_
# ... analyze hierarchical structure ...
The layer 0 is always the most fine-grained layer as determined by ``base_min_cluster_size`` or ``base_n_clusters``.
Each subsequent layer provides a coarser clustering, with fewer clusters. In general the most fine-grained layers
will have the most noise points, and the coarser layers will have fewer noise points. The persistence score
provides a measure of how stable each layer is across different parameter settings, with higher scores indicating more robust clusters.
If you are interested in getting very fine-grained clusters it is worth setting ``base_min_cluster_size`` or ``base_n_clusters``
explicitly to ensure you get clustering at that granularity. You can then inspect the other layers to see if the other natural
granularities align with your use case. If you are only interested in a single clustering, you can set ``approx_n_clusters``
to get the layer that is closest to that number of clusters.
You can also make use of the tree structure to analyze how clusters evolve across layers, and to identify stable clusters
that persist across multiple layers. Alternatively you can use the tree structure to create a "mixed" resolution layer by selecting
clusters at a given layer, and then also selecting any clusters in lower layers that are no children of any of your selected clusters.
This allows you to get a more fine-grained clustering in some parts of the data, while keeping a coarser clustering i
n other parts of the data.
Performance Optimization
~~~~~~~~~~~~~~~~~~~~~~~~
Depending on your needs you may be willing to trade off some accuracy for speed, or vice versa.
The default EVoC parameters are designed primarily for exploratory clustering, and thus produce clusters very quickly.
If you are looking for a more robust higher quality clustering, it can be worth tweaking the parameters to spend
more time to produce a better clustering result. For example, for a medium sized dataset (e.g. 10k-100k points)
you can increase the number of epochs and neighbors to get a better embedding, which will lead to better clusters.
In such cases you will also likely want to fix a random seed to ensure reproducibility, as the optimization process is stochastic.
.. code-block:: python
clusterer = EVoC(
n_epochs=150, # More epochs for better embedding
random_state=42 # Enable optimizations
)
For larger datasets, you may want to reduce the number of neighbors and epochs to get a faster result, at the cost of some cluster quality.
In that case not setting a random seed can actually improve performance, as it allows the algorithm to skip some of the overhead
of ensuring reproducibility.
.. code-block:: python
clusterer = EVoC(
n_neighbors=10, # Balance between quality and speed
n_epochs=30, # Fewer epochs for faster embedding
max_layers=3, # Limit hierarchy depth
)
Troubleshooting
---------------
**Problem**: Too many small clusters
**Solution**: Increase base_min_cluster_size or noise_level
**Problem**: Most points classified as noise
**Solution**: Decrease noise_level or reduce min_samples
**Problem**: Clustering too slow
**Solution**: Reduce n_neighbors, n_epochs, or max_layers
**Problem**: Poor cluster quality
**Solution**: Increase n_neighbors, n_epochs, or try different node_embedding_init
**Problem**: Inconsistent results
**Solution**: Set random_state for reproducible results
================================================
FILE: evoc/__init__.py
================================================
from .clustering import evoc_clusters, EVoC
================================================
FILE: evoc/boruvka.py
================================================
import numba
import numpy as np
from .disjoint_set import RankDisjointSetType, ds_rank_create, ds_find, ds_union_by_rank
from .numba_kdtree import (
NumbaKDTreeType,
parallel_tree_query,
rdist,
point_to_node_lower_bound_rdist,
NumbaKDTree,
)
@numba.njit(
numba.float32[:, ::1](
RankDisjointSetType,
numba.int32[::1],
numba.types.Array(numba.float32, 1, "A"),
numba.int64[::1],
),
locals={"i": numba.types.int64},
cache=True,
)
def merge_components(
disjoint_set, candidate_neighbors, candidate_neighbor_distances, point_components
):
component_edges = {
np.int64(0): (np.int64(0), np.int64(1), np.float32(0.0)) for i in range(0)
}
# Find the best edges from each component
for i in range(candidate_neighbors.shape[0]):
from_component = np.int64(point_components[i])
if from_component in component_edges:
if candidate_neighbor_distances[i] < component_edges[from_component][2]:
component_edges[from_component] = (
numba.int64(i),
numba.int64(candidate_neighbors[i]),
numba.float32(candidate_neighbor_distances[i]),
)
else:
component_edges[from_component] = (
numba.int64(i),
numba.int64(candidate_neighbors[i]),
numba.float32(candidate_neighbor_distances[i]),
)
result = np.empty((len(component_edges), 3), dtype=np.float32)
result_idx = 0
# Add the best edges to the edge set and merge the relevant components
for edge in component_edges.values():
from_component = ds_find(disjoint_set, numba.int32(edge[0]))
to_component = ds_find(disjoint_set, numba.int32(edge[1]))
if from_component != to_component:
result[result_idx] = (
numba.float32(edge[0]),
numba.float32(edge[1]),
numba.float32(edge[2]),
)
result_idx += 1
ds_union_by_rank(disjoint_set, from_component, to_component)
return result[:result_idx]
@numba.njit(
numba.void(
NumbaKDTreeType,
RankDisjointSetType,
numba.int64[::1],
numba.int64[::1],
),
locals={
"i": numba.types.int32,
"j": numba.types.int32,
"idx": numba.types.int32,
"left": numba.types.int32,
"right": numba.types.int32,
"candidate_component": numba.types.int32,
},
parallel=True,
cache=True,
fastmath=True,
)
def update_component_vectors(tree, disjoint_set, node_components, point_components):
for i in numba.prange(point_components.shape[0]):
point_components[i] = ds_find(disjoint_set, np.int32(i))
for i in range(tree.idx_start.shape[0] - 1, -1, -1):
# Access node information from the separate arrays
is_leaf = tree.is_leaf[i]
idx_start = tree.idx_start[i]
idx_end = tree.idx_end[i]
# Case 1:
# If the node is a leaf we need to check that every point
# in the node is of the same component
if is_leaf:
candidate_component = point_components[tree.idx_array[idx_start]]
for j in range(idx_start + 1, idx_end):
idx = tree.idx_array[j]
if point_components[idx] != candidate_component:
break
else:
node_components[i] = candidate_component
# Case 2:
# If the node is not a leaf we only need to check
# that both child nodes are in the same component
else:
left = 2 * i + 1
right = left + 1
if node_components[left] == node_components[right]:
node_components[i] = node_components[left]
@numba.njit(
numba.void(
NumbaKDTreeType,
numba.int32,
numba.float32[::1],
numba.float32[::1],
numba.int32[::1],
numba.float32,
numba.types.Array(numba.float32, 1, "A"),
numba.int64,
numba.int64[::1],
numba.int64[::1],
numba.float32,
numba.float32[::1],
),
locals={
"i": numba.types.int32,
"idx": numba.types.int32,
"left": numba.types.int32,
"right": numba.types.int32,
"d": numba.types.float32,
"dist_lower_bound_left": numba.types.float32,
"dist_lower_bound_right": numba.types.float32,
},
cache=True,
fastmath=True,
)
def component_aware_query_recursion(
tree,
node,
point,
heap_p,
heap_i,
current_core_distance,
core_distances,
current_component,
node_components,
point_components,
dist_lower_bound,
component_nearest_neighbor_dist,
):
# Access node information from the separate arrays
is_leaf = tree.is_leaf[node]
idx_start = tree.idx_start[node]
idx_end = tree.idx_end[node]
# ------------------------------------------------------------
# Case 1a: query point is outside node radius:
# trim it from the query
if dist_lower_bound > heap_p[0]:
return
# ------------------------------------------------------------
# Case 1b: we can't improve on the best distance for this component
# trim it from the query
elif (
dist_lower_bound > component_nearest_neighbor_dist[0]
or current_core_distance > component_nearest_neighbor_dist[0]
):
return
# ------------------------------------------------------------
# Case 1c: node contains only points in same component as query
# trim it from the query
elif node_components[node] == current_component:
return
# ------------------------------------------------------------
# Case 2: this is a leaf node. Update set of nearby points
elif is_leaf:
for i in range(idx_start, idx_end):
idx = tree.idx_array[i]
if (
point_components[idx] != current_component
and core_distances[idx] < component_nearest_neighbor_dist[0]
):
d = max(
rdist(point, tree.data[idx]),
current_core_distance,
core_distances[idx],
)
if d < heap_p[0]:
heap_p[0] = d
heap_i[0] = idx
if d < component_nearest_neighbor_dist[0]:
component_nearest_neighbor_dist[0] = d
# ------------------------------------------------------------
# Case 3: Node is not a leaf. Recursively query subnodes
# starting with the closest
else:
left = numba.int32(2 * node + 1)
right = numba.int32(left + 1)
dist_lower_bound_left = point_to_node_lower_bound_rdist(
tree.node_bounds[0, left], tree.node_bounds[1, left], point
)
dist_lower_bound_right = point_to_node_lower_bound_rdist(
tree.node_bounds[0, right], tree.node_bounds[1, right], point
)
# recursively query subnodes
if dist_lower_bound_left <= dist_lower_bound_right:
component_aware_query_recursion(
tree,
left,
point,
heap_p,
heap_i,
current_core_distance,
core_distances,
current_component,
node_components,
point_components,
dist_lower_bound_left,
component_nearest_neighbor_dist,
)
component_aware_query_recursion(
tree,
right,
point,
heap_p,
heap_i,
current_core_distance,
core_distances,
current_component,
node_components,
point_components,
dist_lower_bound_right,
component_nearest_neighbor_dist,
)
else:
component_aware_query_recursion(
tree,
right,
point,
heap_p,
heap_i,
current_core_distance,
core_distances,
current_component,
node_components,
point_components,
dist_lower_bound_right,
component_nearest_neighbor_dist,
)
component_aware_query_recursion(
tree,
left,
point,
heap_p,
heap_i,
current_core_distance,
core_distances,
current_component,
node_components,
point_components,
dist_lower_bound_left,
component_nearest_neighbor_dist,
)
return
@numba.njit(
numba.types.Tuple((numba.float32[::1], numba.int32[::1]))(
NumbaKDTreeType,
numba.int64[::1],
numba.int64[::1],
numba.types.Array(numba.float32, 1, "A"),
),
locals={
"i": numba.types.int32,
"distance_lower_bound": numba.types.float32,
"current_component": numba.types.int32,
},
parallel=True,
cache=True,
fastmath=True,
)
def boruvka_tree_query(tree, node_components, point_components, core_distances):
candidate_distances = np.full(tree.data.shape[0], np.inf, dtype=np.float32)
candidate_indices = np.full(tree.data.shape[0], -1, dtype=np.int32)
component_nearest_neighbor_dist = np.full(
tree.data.shape[0], np.inf, dtype=np.float32
)
data = tree.data.astype(np.float32)
for i in numba.prange(tree.data.shape[0]):
distance_lower_bound = point_to_node_lower_bound_rdist(
tree.node_bounds[0, 0], tree.node_bounds[1, 0], tree.data[i]
)
heap_p, heap_i = candidate_distances[i : i + 1], candidate_indices[i : i + 1]
component_aware_query_recursion(
tree,
numba.int32(0),
data[i],
heap_p,
heap_i,
core_distances[i],
core_distances,
point_components[i],
node_components,
point_components,
distance_lower_bound,
component_nearest_neighbor_dist[
point_components[i] : point_components[i] + 1
],
)
return candidate_distances, candidate_indices
@numba.njit(inline="always", cache=True)
def calculate_block_size(n_components, n_points, num_threads):
"""Calculate adaptive block size based on component sizes."""
if n_components == 0:
points_per_component = n_points
else:
points_per_component = n_points / n_components
if points_per_component < 10:
block_size = num_threads * 512 # Weak pruning, large blocks
elif points_per_component < 100:
block_size = num_threads * 128 # Moderate pruning
elif points_per_component < 1000:
block_size = num_threads * 32 # Good pruning
else:
block_size = num_threads * 8 # Excellent pruning, small blocks
# Ensure reasonable bounds
block_size = max(num_threads, min(block_size, n_points // 4 + 1))
return int(block_size)
@numba.njit(
[
"void(float32[:], float32[:], int32[:], int32, int32)",
"void(float64[:], float64[:], int64[:], int64, int64)",
],
locals={
"i": numba.types.int32,
"component": numba.types.int32,
"block_bound": numba.types.float32,
},
cache=True,
fastmath=True,
inline="always",
)
def update_component_bounds_from_block(
component_nearest_neighbor_dist,
block_component_bounds,
point_components,
block_start,
block_end,
):
"""Update global component bounds from block results."""
for i in range(block_start, block_end):
component = point_components[i]
block_bound = block_component_bounds[i - block_start]
if block_bound < component_nearest_neighbor_dist[component]:
component_nearest_neighbor_dist[component] = block_bound
@numba.njit(
numba.types.Tuple((numba.float32[::1], numba.int32[::1]))(
NumbaKDTreeType,
numba.int64[::1],
numba.int64[::1],
numba.types.Array(numba.float32, 1, "A"),
numba.int64,
),
locals={
"block_start": numba.types.int32,
"block_end": numba.types.int32,
"block_size_actual": numba.types.int32,
"i": numba.types.int32,
"distance_lower_bound": numba.types.float32,
"current_component": numba.types.int32,
},
parallel=True,
cache=True,
fastmath=True,
)
def boruvka_tree_query_reproducible(
tree, node_components, point_components, core_distances, block_size
):
"""Reproducible version using block-based processing to avoid race conditions."""
candidate_distances = np.full(tree.data.shape[0], np.inf, dtype=np.float32)
candidate_indices = np.full(tree.data.shape[0], -1, dtype=np.int32)
component_nearest_neighbor_dist = np.full(
tree.data.shape[0], np.inf, dtype=np.float32
)
data = tree.data.astype(np.float32)
# Reusable buffer for block component bounds (allocate once, reuse)
max_block_component_bounds = np.full(block_size, np.inf, dtype=np.float32)
# Process points in blocks
for block_start in range(0, tree.data.shape[0], block_size):
block_end = min(block_start + block_size, tree.data.shape[0])
block_size_actual = block_end - block_start
# Reset only the portion we'll use (more cache-friendly)
max_block_component_bounds[:block_size_actual] = np.inf
# Parallel processing within the block
for i in numba.prange(block_start, block_end):
distance_lower_bound = point_to_node_lower_bound_rdist(
tree.node_bounds[0, 0], tree.node_bounds[1, 0], tree.data[i]
)
heap_p, heap_i = (
candidate_distances[i : i + 1],
candidate_indices[i : i + 1],
)
# Use current global bounds for this component
current_component = point_components[i]
local_component_bound = component_nearest_neighbor_dist[
current_component : current_component + 1
]
component_aware_query_recursion(
tree,
numba.int32(0),
data[i],
heap_p,
heap_i,
core_distances[i],
core_distances,
point_components[i],
node_components,
point_components,
distance_lower_bound,
local_component_bound,
)
# Store the potentially updated bound for this point
max_block_component_bounds[i - block_start] = local_component_bound[0]
# Sequential update of global component bounds after the block
update_component_bounds_from_block(
component_nearest_neighbor_dist,
max_block_component_bounds,
point_components,
block_start,
block_end,
)
return candidate_distances, candidate_indices
@numba.njit(
locals={
"i": numba.types.int32,
"j": numba.types.int32,
"k": numba.types.int32,
"result_idx": numba.types.int32,
"from_component": numba.types.int32,
"to_component": numba.types.int32,
},
parallel=True,
cache=True,
)
def initialize_boruvka_from_knn(
knn_indices, knn_distances, core_distances, disjoint_set
):
# component_edges = {0:(np.int32(0), np.int32(1), np.float32(0.0)) for i in range(0)}
component_edges = np.full((knn_indices.shape[0], 3), -1, dtype=np.float64)
for i in numba.prange(knn_indices.shape[0]):
for j in range(1, knn_indices.shape[1]):
k = np.int32(knn_indices[i, j])
if core_distances[i] >= core_distances[k]:
# Use max of core distance and actual distance as edge weight
edge_weight = max(core_distances[i], knn_distances[i, j])
component_edges[i] = (
np.float64(i),
np.float64(k),
np.float64(edge_weight),
)
break
result = np.empty((len(component_edges), 3), dtype=np.float64)
result_idx = 0
# Add the best edges to the edge set and merge the relevant components
for edge in component_edges:
if edge[0] < 0:
continue
from_component = ds_find(disjoint_set, np.int32(edge[0]))
to_component = ds_find(disjoint_set, np.int32(edge[1]))
if from_component != to_component:
result[result_idx] = (
np.float64(edge[0]),
np.float64(edge[1]),
np.float64(edge[2]),
)
result_idx += 1
ds_union_by_rank(disjoint_set, from_component, to_component)
return result[:result_idx].astype(np.float32)
@numba.njit(
numba.float32[:, ::1](
NumbaKDTreeType,
numba.int64,
numba.int64,
numba.types.boolean,
),
cache=True,
)
def parallel_boruvka(tree, n_threads, min_samples=10, reproducible=False):
components_disjoint_set = ds_rank_create(tree.data.shape[0])
point_components = np.arange(tree.data.shape[0])
node_components = np.full(tree.idx_start.shape[0], -1)
n_components = point_components.shape[0]
if min_samples > 1:
distances, neighbors = parallel_tree_query(
tree, tree.data, k=numba.int64(min_samples + 1), output_rdist=True
)
core_distances = distances.T[-1]
initial_edges = initialize_boruvka_from_knn(
neighbors, distances, core_distances, components_disjoint_set
)
update_component_vectors(
tree, components_disjoint_set, node_components, point_components
)
else:
core_distances = np.zeros(tree.data.shape[0], dtype=np.float32)
distances, neighbors = parallel_tree_query(
tree, tree.data, k=numba.int64(2), output_rdist=True
)
initial_edges = initialize_boruvka_from_knn(
neighbors, distances, core_distances, components_disjoint_set
)
update_component_vectors(
tree, components_disjoint_set, node_components, point_components
)
# Count initial components after initialization
n_components = len(np.unique(point_components))
# Use list to accumulate edges, then convert at end (more efficient than vstack)
# all_edges = [initial_edges]
# all_edges = initial_edges
max_edges = tree.data.shape[0] - 1
all_edges = np.empty((max_edges, 3), dtype=np.float32)
n_edges = numba.int64(len(initial_edges))
all_edges[:n_edges] = initial_edges
while n_components > 1:
if reproducible:
# Calculate adaptive block size based on current component sizes
block_size = calculate_block_size(
n_components, tree.data.shape[0], n_threads
)
candidate_distances, candidate_indices = boruvka_tree_query_reproducible(
tree, node_components, point_components, core_distances, block_size
)
else:
candidate_distances, candidate_indices = boruvka_tree_query(
tree, node_components, point_components, core_distances
)
new_edges = merge_components(
components_disjoint_set,
candidate_indices,
candidate_distances,
point_components,
)
# Update component count more efficiently - subtract merged components
n_components -= len(new_edges)
update_component_vectors(
tree, components_disjoint_set, node_components, point_components
)
if len(new_edges) > 0:
# # all_edges.append(new_edges)
# all_edges = np.vstack((all_edges, new_edges)).astype(np.float32)
all_edges[n_edges : n_edges + len(new_edges)] = new_edges
n_edges += numba.int64(len(new_edges))
all_edges[:, 2] = np.sqrt(all_edges.T[2])
return all_edges
================================================
FILE: evoc/cluster_trees.py
================================================
import numba
import numpy as np
from collections import namedtuple
from .disjoint_set import ds_rank_create, ds_find, ds_union_by_rank
LinkageMergeData = namedtuple("LinkageMergeData", ["parent", "size", "next"])
@numba.njit(cache=True)
def create_linkage_merge_data(base_size):
parent = np.full(2 * base_size - 1, -1, dtype=np.intp)
size = np.concatenate(
(np.ones(base_size, dtype=np.intp), np.zeros(base_size - 1, dtype=np.intp))
)
next_parent = np.array([base_size], dtype=np.intp)
return LinkageMergeData(parent, size, next_parent)
@numba.njit(cache=True)
def linkage_merge_find(linkage_merge, node):
relabel = node
while linkage_merge.parent[node] != -1 and linkage_merge.parent[node] != node:
node = linkage_merge.parent[node]
linkage_merge.parent[node] = node
# label up to the root
while linkage_merge.parent[relabel] != node:
next_relabel = linkage_merge.parent[relabel]
linkage_merge.parent[relabel] = node
relabel = next_relabel
return node
@numba.njit(cache=True)
def linkage_merge_join(linkage_merge, left, right):
linkage_merge.size[linkage_merge.next[0]] = (
linkage_merge.size[left] + linkage_merge.size[right]
)
linkage_merge.parent[left] = linkage_merge.next[0]
linkage_merge.parent[right] = linkage_merge.next[0]
linkage_merge.next[0] += 1
@numba.njit(cache=True)
def mst_to_linkage_tree(sorted_mst):
result = np.empty((sorted_mst.shape[0], sorted_mst.shape[1] + 1))
n_samples = sorted_mst.shape[0] + 1
linkage_merge = create_linkage_merge_data(n_samples)
for index in range(sorted_mst.shape[0]):
left = np.intp(sorted_mst[index, 0])
right = np.intp(sorted_mst[index, 1])
delta = sorted_mst[index, 2]
left_component = linkage_merge_find(linkage_merge, left)
right_component = linkage_merge_find(linkage_merge, right)
if left_component > right_component:
result[index][0] = left_component
result[index][1] = right_component
else:
result[index][1] = left_component
result[index][0] = right_component
result[index][2] = delta
result[index][3] = (
linkage_merge.size[left_component] + linkage_merge.size[right_component]
)
linkage_merge_join(linkage_merge, left_component, right_component)
return result
@numba.njit(cache=True)
def bfs_from_hierarchy(hierarchy, bfs_root, num_points):
to_process = [bfs_root]
result = []
while to_process:
result.extend(to_process)
next_to_process = []
for n in to_process:
if n >= num_points:
i = n - num_points
next_to_process.append(int(hierarchy[i, 0]))
next_to_process.append(int(hierarchy[i, 1]))
to_process = next_to_process
return result
@numba.njit(cache=True)
def eliminate_branch(
branch_node,
parent_node,
lambda_value,
parents,
children,
lambdas,
sizes,
idx,
ignore,
hierarchy,
num_points,
):
if branch_node < num_points:
parents[idx] = parent_node
children[idx] = branch_node
lambdas[idx] = lambda_value
idx += 1
else:
for sub_node in bfs_from_hierarchy(hierarchy, branch_node, num_points):
if sub_node < num_points:
children[idx] = sub_node
parents[idx] = parent_node
lambdas[idx] = lambda_value
idx += 1
else:
ignore[sub_node] = True
return idx
CondensedTree = namedtuple(
"CondensedTree", ["parent", "child", "lambda_val", "child_size"]
)
@numba.njit(fastmath=True, cache=True)
def condense_tree(hierarchy, min_cluster_size=10):
root = 2 * hierarchy.shape[0]
num_points = hierarchy.shape[0] + 1
next_label = num_points + 1
node_list = bfs_from_hierarchy(hierarchy, root, num_points)
relabel = np.zeros(root + 1, dtype=np.int64)
relabel[root] = num_points
parents = np.ones(root, dtype=np.int64)
children = np.empty(root, dtype=np.int64)
lambdas = np.empty(root, dtype=np.float32)
sizes = np.ones(root, dtype=np.int64)
ignore = np.zeros(root + 1, dtype=np.bool_)
idx = 0
for node in node_list:
if ignore[node] or node < num_points:
continue
parent_node = relabel[node]
l, r, d, _ = hierarchy[node - num_points]
left = np.int64(l)
right = np.int64(r)
if d > 0.0:
lambda_value = 1.0 / d
else:
lambda_value = np.inf
left_count = (
np.int64(hierarchy[left - num_points, 3]) if left >= num_points else 1
)
right_count = (
np.int64(hierarchy[right - num_points, 3]) if right >= num_points else 1
)
# The logic here is in a strange order, but it has non-trivial performance gains ...
# The most common case by far is a singleton on the left; and cluster on the right take care of this separately
if left < num_points and right_count >= min_cluster_size:
relabel[right] = parent_node
parents[idx] = parent_node
children[idx] = left
lambdas[idx] = lambda_value
idx += 1
# Next most common is a small left cluster and a large right cluster: relabel the right node; eliminate the left branch
elif left_count < min_cluster_size and right_count >= min_cluster_size:
relabel[right] = parent_node
idx = eliminate_branch(
left,
parent_node,
lambda_value,
parents,
children,
lambdas,
sizes,
idx,
ignore,
hierarchy,
num_points,
)
# Then we have a large left cluster and a small right cluster: relabel the left node; elimiate the right branch
elif left_count >= min_cluster_size and right_count < min_cluster_size:
relabel[left] = parent_node
idx = eliminate_branch(
right,
parent_node,
lambda_value,
parents,
children,
lambdas,
sizes,
idx,
ignore,
hierarchy,
num_points,
)
# If both clusters are small then eliminate all branches
elif left_count < min_cluster_size and right_count < min_cluster_size:
idx = eliminate_branch(
left,
parent_node,
lambda_value,
parents,
children,
lambdas,
sizes,
idx,
ignore,
hierarchy,
num_points,
)
idx = eliminate_branch(
right,
parent_node,
lambda_value,
parents,
children,
lambdas,
sizes,
idx,
ignore,
hierarchy,
num_points,
)
# and finally if we actually have a legitimate cluster split, handle that correctly
else:
relabel[left] = next_label
parents[idx] = parent_node
children[idx] = next_label
lambdas[idx] = lambda_value
sizes[idx] = left_count
next_label += 1
idx += 1
relabel[right] = next_label
parents[idx] = parent_node
children[idx] = next_label
lambdas[idx] = lambda_value
sizes[idx] = right_count
next_label += 1
idx += 1
return CondensedTree(parents[:idx], children[:idx], lambdas[:idx], sizes[:idx])
@numba.njit(cache=True)
def extract_leaves(condensed_tree, allow_single_cluster=True):
# Handle empty tree case gracefully
if len(condensed_tree.parent) == 0:
return np.zeros(0, dtype=np.intp)
n_nodes = condensed_tree.parent.max() + 1
n_points = condensed_tree.parent.min()
leaf_indicator = np.ones(n_nodes, dtype=np.bool_)
leaf_indicator[:n_points] = False
for parent, child_size in zip(condensed_tree.parent, condensed_tree.child_size):
if child_size > 1:
leaf_indicator[parent] = False
return np.nonzero(leaf_indicator)[0]
@numba.njit(cache=True, fastmath=True)
def score_condensed_tree_nodes(condensed_tree):
result = {0: 0.0 for i in range(0)}
for i in range(condensed_tree.parent.shape[0]):
parent = condensed_tree.parent[i]
if parent in result:
result[parent] += (
condensed_tree.lambda_val[i] * condensed_tree.child_size[i]
)
else:
result[parent] = condensed_tree.lambda_val[i] * condensed_tree.child_size[i]
if condensed_tree.child_size[i] > 1:
child = condensed_tree.child[i]
if child in result:
result[child] -= (
condensed_tree.lambda_val[i] * condensed_tree.child_size[i]
)
else:
result[child] = (
-condensed_tree.lambda_val[i] * condensed_tree.child_size[i]
)
return result
@numba.njit(cache=True)
def cluster_tree_from_condensed_tree(condensed_tree):
mask = condensed_tree.child_size > 1
return CondensedTree(
condensed_tree.parent[mask],
condensed_tree.child[mask],
condensed_tree.lambda_val[mask],
condensed_tree.child_size[mask],
)
@numba.njit(cache=True)
def mask_condensed_tree(condensed_tree, mask):
return CondensedTree(
condensed_tree.parent[mask],
condensed_tree.child[mask],
condensed_tree.lambda_val[mask],
condensed_tree.child_size[mask]
)
@numba.njit(cache=True)
def unselect_below_node(node, cluster_tree, selected_clusters):
for child in cluster_tree.child[cluster_tree.parent == node]:
unselect_below_node(child, cluster_tree, selected_clusters)
selected_clusters[child] = False
@numba.njit(fastmath=True, cache=True)
def eom_recursion(node, cluster_tree, node_scores, selected_clusters):
current_score = node_scores[node]
children = cluster_tree.child[cluster_tree.parent == node]
child_score_total = 0.0
for child_node in children:
child_score_total += eom_recursion(
child_node, cluster_tree, node_scores, selected_clusters
)
if child_score_total > current_score:
return child_score_total
else:
selected_clusters[node] = True
unselect_below_node(node, cluster_tree, selected_clusters)
return current_score
@numba.njit(cache=True)
def extract_eom_clusters(condensed_tree, cluster_tree, allow_single_cluster=False):
node_scores = score_condensed_tree_nodes(condensed_tree)
selected_clusters = {node: False for node in node_scores}
if len(cluster_tree.parent) == 0:
return np.zeros(0, dtype=np.int64)
cluster_tree_root = cluster_tree.parent.min()
if allow_single_cluster:
eom_recursion(cluster_tree_root, cluster_tree, node_scores, selected_clusters)
elif len(node_scores) > 1:
root_children = cluster_tree.child[cluster_tree.parent == cluster_tree_root]
for child_node in root_children:
eom_recursion(child_node, cluster_tree, node_scores, selected_clusters)
return np.asarray(
[node for node, selected in selected_clusters.items() if selected]
)
@numba.njit(cache=True)
def cluster_epsilon_search(clusters, cluster_tree, min_persistence=0.0):
selected = list()
# only way to create a typed empty set
processed = {np.int64(0)}
processed.clear()
root = cluster_tree.parent.min()
for cluster in clusters:
eps = 1 / cluster_tree.lambda_val[cluster_tree.child == cluster][0]
if eps < min_persistence:
if cluster not in processed:
parent = traverse_upwards(cluster_tree, min_persistence, root, cluster)
selected.append(parent)
processed |= segments_in_branch(cluster_tree, parent)
else:
selected.append(cluster)
return np.asarray(selected)
@numba.njit(cache=True)
def traverse_upwards(cluster_tree, min_persistence, root, segment):
parent = cluster_tree.parent[cluster_tree.child == segment][0]
if parent == root:
return root
parent_eps = 1 / cluster_tree.lambda_val[cluster_tree.child == parent][0]
if parent_eps >= min_persistence:
return parent
else:
return traverse_upwards(cluster_tree, min_persistence, root, parent)
@numba.njit(cache=True)
def segments_in_branch(cluster_tree, segment):
# only way to create a typed empty set
result = {np.intp(0)}
result.clear()
to_process = {segment}
while len(to_process) > 0:
result |= to_process
to_process = set(
cluster_tree.child[in_set_parallel(cluster_tree.parent, to_process)]
)
return result
@numba.njit(parallel=True, cache=True)
def in_set_parallel(values, targets):
mask = np.empty(values.shape[0], dtype=numba.boolean)
for i in numba.prange(values.shape[0]):
mask[i] = values[i] in targets
return mask
@numba.njit(parallel=True, cache=True)
def get_cluster_labelling_at_cut(linkage_tree, cut, min_cluster_size):
root = 2 * linkage_tree.shape[0]
num_points = linkage_tree.shape[0] + 1
result = np.empty(num_points, dtype=np.intp)
disjoint_set = ds_rank_create(root + 1)
cluster = num_points
for i in range(linkage_tree.shape[0]):
if linkage_tree[i, 2] < cut:
ds_union_by_rank(disjoint_set, np.intp(linkage_tree[i, 0]), cluster)
ds_union_by_rank(disjoint_set, np.intp(linkage_tree[i, 1]), cluster)
cluster += 1
cluster_size = np.zeros(cluster, dtype=np.intp)
for n in range(num_points):
cluster = ds_find(disjoint_set, n)
cluster_size[cluster] += 1
result[n] = cluster
cluster_label_map = {-1: -1}
cluster_label = 0
unique_labels = np.unique(result)
for cluster in unique_labels:
if cluster_size[cluster] < min_cluster_size:
cluster_label_map[cluster] = -1
else:
cluster_label_map[cluster] = cluster_label
cluster_label += 1
for n in numba.prange(num_points):
result[n] = cluster_label_map[result[n]]
return result
@numba.njit(cache=True)
def get_single_cluster_label_vector(
tree,
cluster,
cluster_selection_epsilon,
n_samples,
):
if len(tree.parent) == 0:
return np.full(n_samples, -1, dtype=np.intp)
result = np.full(n_samples, -1, dtype=np.intp)
max_lambda = tree.lambda_val[tree.parent == cluster].max()
for i in range(tree.child.shape[0]):
n = tree.child[i]
cur_lambda = tree.lambda_val[i]
if cluster_selection_epsilon > 0.0:
if cur_lambda >= 1 / cluster_selection_epsilon:
result[n] = 0
else:
result[n] = -1
elif cur_lambda >= max_lambda:
result[n] = 0
return result
@numba.njit(cache=True)
def get_cluster_label_vector(
tree,
clusters,
cluster_selection_epsilon,
n_samples,
):
if len(clusters) == 1:
return get_single_cluster_label_vector(
tree, clusters[0], cluster_selection_epsilon, n_samples
)
if len(tree.parent) == 0:
return np.full(n_samples, -1, dtype=np.intp)
root_cluster = tree.parent.min()
result = np.full(n_samples, -1, dtype=np.intp)
cluster_label_map = {c: n for n, c in enumerate(np.sort(clusters))}
disjoint_set = ds_rank_create(max(tree.parent.max() + 1, tree.child.max() + 1))
clusters = set(clusters)
for n in range(tree.parent.shape[0]):
child = tree.child[n]
parent = tree.parent[n]
if child not in clusters:
ds_union_by_rank(disjoint_set, parent, child)
for n in range(n_samples):
cluster = ds_find(disjoint_set, n)
if cluster <= root_cluster:
result[n] = -1
else:
result[n] = cluster_label_map[cluster]
return result
@numba.njit(cache=True)
def max_lambdas(tree, clusters):
result = {c: 0.0 for c in clusters}
for n in range(tree.parent.shape[0]):
cluster = tree.parent[n]
if cluster in clusters and tree.child_size[n] == 1:
result[cluster] = max(result[cluster], tree.lambda_val[n])
return result
@numba.njit(cache=True)
def get_point_membership_strength_vector(tree, clusters, labels):
result = np.zeros(labels.shape[0], dtype=np.float32)
deaths = max_lambdas(tree, set(clusters))
root_cluster = tree.parent.min()
cluster_index_map = {n: c for n, c in enumerate(np.sort(clusters))}
for n in range(tree.child.shape[0]):
point = tree.child[n]
if point >= root_cluster or labels[point] < 0:
continue
cluster = cluster_index_map[labels[point]]
max_lambda = deaths[cluster]
if max_lambda == 0.0 or not np.isfinite(tree.lambda_val[n]):
result[point] = 1.0
else:
lambda_val = min(tree.lambda_val[n], max_lambda)
result[point] = lambda_val / max_lambda
return result
================================================
FILE: evoc/clustering.py
================================================
import numpy as np
import numba
from sklearn.base import BaseEstimator, ClusterMixin
from sklearn.utils import check_array, check_random_state
from sklearn.utils.validation import check_is_fitted
from .numba_kdtree import build_kdtree
from .boruvka import parallel_boruvka
from .cluster_trees import (
mst_to_linkage_tree,
condense_tree,
mask_condensed_tree,
extract_leaves,
get_cluster_label_vector,
get_point_membership_strength_vector,
)
from .clustering_utilities import (
find_peaks,
_binary_search_for_n_clusters,
binary_search_for_n_clusters,
min_cluster_size_barcode,
compute_total_persistence,
extract_clusters_by_id,
select_diverse_peaks,
build_cluster_tree,
find_duplicates,
)
from .knn_graph import knn_graph
from .label_propagation import label_propagation_init
from .node_embedding import node_embedding
from .graph_construction import neighbor_graph_matrix
def build_cluster_layers(
data,
*,
min_samples=5,
base_min_cluster_size=10,
base_n_clusters=None,
reproducible_flag=False,
min_similarity_threshold=0.2,
max_layers=10,
):
"""Build hierarchical cluster layers from embedding data.
Parameters
----------
data : array-like of shape (n_samples, n_features)
The embedding data to cluster. Typically the output of a node embedding
algorithm.
min_samples : int, default=5
The minimum number of samples to use in the density estimation when
performing density based clustering.
base_min_cluster_size : int, default=10
The minimum number of points in a cluster at the base layer of the clustering.
This gives the finest granularity clustering that will be returned.
base_n_clusters : int or None, default=None
If not None, the algorithm will attempt to find the granularity of
clustering that will give exactly this many clusters for the bottom-most layer
of clustering. This affects the base layer computation and allows multiple
layers to be built on top of this base.
reproducible_flag : bool, default=False
Whether to ensure reproducible results by using deterministic algorithms
where possible.
min_similarity_threshold : float, default=0.2
The minimum similarity threshold for cluster layer selection. Peaks that result
in clusterings with Jaccard similarity above this threshold will be filtered out
to ensure diverse cluster layers.
max_layers : int, default=10
The maximum number of cluster layers to return. The algorithm will select up to
this many diverse peaks based on persistence and similarity criteria.
Returns
-------
cluster_layers : list of array-like of shape (n_samples,)
The clustering of the data at each layer of the clustering. Each layer
is a clustering of the data into a different number of clusters.
membership_strength_layers : list of array-like of shape (n_samples,)
The membership strengths of each point in the clustering at each layer.
This gives a measure of how strongly each point belongs to each cluster.
persistence_scores : list of float
The persistence scores for each cluster layer, indicating the quality or
stability of the clustering at that layer.
"""
n_samples = data.shape[0]
min_cluster_size = base_min_cluster_size
cluster_layers = []
membership_strength_layers = []
persistence_scores = []
n_threads = numba.get_num_threads()
numba_tree = build_kdtree(data.astype(np.float32))
edges = parallel_boruvka(
numba_tree,
n_threads,
min_samples=min_cluster_size if min_samples is None else min_samples,
reproducible=reproducible_flag,
)
sorted_mst = edges[np.argsort(edges.T[2])]
uncondensed_tree = mst_to_linkage_tree(sorted_mst)
if base_n_clusters is not None:
leaves, clusters, strengths = _binary_search_for_n_clusters(
uncondensed_tree, base_n_clusters, n_samples=n_samples
)
cluster_sizes = np.bincount(clusters[clusters >= 0])
if len(cluster_sizes) > 0:
min_cluster_size = max(1, np.min(cluster_sizes))
else:
min_cluster_size = base_min_cluster_size
# Still need condensed tree for later processing
condensed_tree = condense_tree(uncondensed_tree, min_cluster_size)
else:
condensed_tree = condense_tree(uncondensed_tree, base_min_cluster_size)
leaves = extract_leaves(condensed_tree)
clusters = get_cluster_label_vector(condensed_tree, leaves, 0.0, n_samples)
strengths = get_point_membership_strength_vector(
condensed_tree, leaves, clusters
)
mask = condensed_tree.child >= n_samples
cluster_tree = mask_condensed_tree(condensed_tree, mask)
# points_tree = mask_condensed_tree(condensed_tree, ~mask)
# Check if cluster_tree is valid before processing
if len(cluster_tree.child) > 0 and cluster_tree.child[-1] >= n_samples:
births, deaths, parents, lambda_deaths = min_cluster_size_barcode(
cluster_tree, n_samples, min_cluster_size
)
sizes, total_persistence = compute_total_persistence(
births, deaths, lambda_deaths
)
peaks = find_peaks(total_persistence)
else:
# Handle empty or invalid cluster tree
births = np.array([])
deaths = np.array([])
parents = np.array([])
lambda_deaths = np.array([])
sizes = np.array([])
total_persistence = np.array([])
peaks = np.array([], dtype=np.int64)
# Always include the base layer (from initial condensed tree)
cluster_layers.append(clusters)
membership_strength_layers.append(strengths)
persistence_scores.append(0.0) # Base layer gets 0 persistence score
# Select diverse peaks using hierarchical selection
selected_peaks = select_diverse_peaks(
peaks,
total_persistence,
sizes,
births,
deaths,
min_similarity_threshold=min_similarity_threshold,
max_layers=max_layers - 1, # Reserve one slot for base layer
)
for peak in selected_peaks:
best_birth = sizes[peak]
persistence = total_persistence[peak]
selected_clusters = (
np.where((births <= best_birth) & (deaths > best_birth))[0] + n_samples
)
labels, strengths = extract_clusters_by_id(condensed_tree, selected_clusters)
cluster_layers.append(labels)
membership_strength_layers.append(strengths)
persistence_scores.append(persistence)
# Sort cluster layers by number of clusters (most clusters first)
n_clusters_per_layer = [layer.max() + 1 for layer in cluster_layers]
sorted_indices = np.argsort(n_clusters_per_layer)[::-1] # Descending order
cluster_layers = [cluster_layers[i] for i in sorted_indices]
membership_strength_layers = [membership_strength_layers[i] for i in sorted_indices]
persistence_scores = [persistence_scores[i] for i in sorted_indices]
return cluster_layers, membership_strength_layers, persistence_scores
def evoc_clusters(
data,
noise_level=0.5,
base_min_cluster_size=5,
base_n_clusters=None,
approx_n_clusters=None,
n_neighbors=15,
min_samples=5,
n_epochs=50,
node_embedding_init="label_prop",
symmetrize_graph=True,
return_duplicates=False,
node_embedding_dim=None,
neighbor_scale=1.0,
random_state=None,
reproducible_flag=True,
min_similarity_threshold=0.2,
max_layers=10,
n_label_prop_iter=20,
):
"""Cluster data using the EVoC algorithm.
Parameters
----------
data : array-like of shape (n_samples, n_features)
The data to cluster. If the data is float valued then it is assumed to use
cosine distance as a matric. If the data is int8 valued then it is assumed
that a quantized embedding is being used and a quantized version of cosine
distance is used. If the data is uint8 valued then it is assumed that a
binary embedding is being used, and a bitwise Jaccard distance is used.
noise_level : float, default=0.5
The noise level expected in the data. A value of 0.0 will try to cluster
more data, at the expense of getting less accurate clustering. A value of
1.0 will try for accurate clusters, discarding more data as noise to do so.
base_min_cluster_size : int, default=5
The minimum number of points in a cluster at the base layer of the clustering.
This gives the finest granularity clustering that will be returned, with less
graularity at higher layers.
base_n_clusters : int, default=None
If not None, the algorithm will attempt to find the granularity of
clustering that will give exactly this many clusters for the bottom-most layer
of clustering. This affects the base layer computation and allows multiple
layers to be built on top of this base. Since the actual number of clusters
cannot be guaranteed this is only approximate, but usually the algorithm can
manage to get this exact number, assuming a reasonable clustering into
``base_n_clusters`` exists.
approx_n_clusters : int, default=None
If not None, the algorithm will attempt to find the granularity of
clustering that will give exactly this many clusters as the final output.
Unlike ``base_n_clusters``, when this parameter is set, only a single
clustering layer will be returned -- no hierarchical layers will be produced.
This is useful when you know the exact number of clusters you want and don't
need the multi-layer analysis. Since the actual number of clusters cannot be
guaranteed this is only approximate, but usually the algorithm can manage to
get this exact number, assuming a reasonable clustering into ``approx_n_clusters``
exists.
n_neighbors : int, default=15
The number of neighbors to use in the nearest neighbor graph construction.
min_samples : int, default=5
The minimum number of samples to use in the density estimation when
performing density based clustering on the node embedding.
n_epochs : int, default=50
The number of epochs to use when training the node embedding.
node_embedding_init : str or None, default='label_prop'
The method to use to initialize the node embedding. If None, no initialization
will be used. If 'label_prop', the label propagation method will be used.
symmetrize_graph : bool, default=True
Whether to symmetrize the nearest neighbor graph before using it to
construct the node embedding.
return_duplicates : bool, default=False
Whether to return a set of duplicate pairs of points in the data.
node_embedding_dim : int or None, default=None
The number of dimensions to use in the node embedding. If None, a default
value of min(max(n_neighbors // 4, 4), 15) will be used.
neighbor_scale : float, default=1.0
The scale factor to use when constructing the nearest neighbor graph. This
multiplies the effective number of neighbors used in graph construction
(neighbor_scale * n_neighbors). Values > 1.0 create denser graphs with more
connectivity, potentially capturing more global structure but at increased
computational cost. Values < 1.0 create sparser graphs focused on local
structure.
random_state : np.random.RandomState or None, default=None
The random state to use for the random number generator. If None, the random
number generator will not be seeded and will use the system time as the seed.
reproducible_flag : bool, default=True
Whether to ensure reproducible results by using deterministic algorithms
where possible. When True, the clustering results should be consistent
across runs with the same random_state.
min_similarity_threshold : float, default=0.2
The minimum similarity threshold for cluster layer selection. Peaks that result
in clusterings with Jaccard similarity above this threshold will be filtered out
to ensure diverse cluster layers.
max_layers : int, default=10
The maximum number of cluster layers to return. The algorithm will select up to
this many diverse peaks based on persistence and similarity criteria.
n_label_prop_iter : int, default=20
The number of iterations to use in the label propagation algorithm when
initializing the node embedding.
Returns
-------
cluster_layers : list of array-like of shape (n_samples,)
The clustering of the data at each layer of the clustering. Each layer
is a clustering of the data into a different number of clusters.
membership_strengths : list of array-like of shape (n_samples,)
The membership strengths of each point in the clustering at each layer.
This gives a measure of how strongly each point belongs to each cluster.
nn_inds : array-like of shape (n_samples, n_neighbors)
Indices of nearest neighbors for each sample.
nn_dists : array-like of shape (n_samples, n_neighbors)
Distance from each sample to each nearest neighbor indexed by nn_inds
duplicates : set of tuple of int
Only returned in ``return_duplicates`` is True. A set of pairs of indices of
potential duplicate points in the data.
"""
if random_state is None:
random_state = np.random.RandomState()
nn_inds, nn_dists = knn_graph(
data, n_neighbors=n_neighbors, random_state=random_state
)
graph = neighbor_graph_matrix(
neighbor_scale * n_neighbors, nn_inds, nn_dists, symmetrize_graph
)
n_embedding_components = node_embedding_dim or min(max(n_neighbors // 4, 4), 15)
if node_embedding_init == "label_prop":
init_embedding = label_propagation_init(
graph,
n_components=n_embedding_components,
approx_n_parts=np.clip(int(8 * np.sqrt(data.shape[0])), 256, 16384),
random_scale=0.1,
scaling=0.5,
noise_level=noise_level,
random_state=random_state,
data=data,
n_label_prop_iter=n_label_prop_iter,
)
elif node_embedding_init is None:
init_embedding = None
embedding = node_embedding(
graph,
n_components=n_embedding_components,
n_epochs=n_epochs,
initial_embedding=init_embedding,
negative_sample_rate=1.0,
noise_level=noise_level,
random_state=random_state,
verbose=False,
reproducible_flag=reproducible_flag,
initial_alpha=0.1,
)
if return_duplicates:
duplicates = find_duplicates(nn_inds, nn_dists)
n_threads = numba.get_num_threads()
if approx_n_clusters is not None:
cluster_vector, strengths = binary_search_for_n_clusters(
embedding,
approx_n_clusters,
n_threads,
min_samples=min_samples,
)
if return_duplicates:
return [cluster_vector], [strengths], [0.0], nn_inds, nn_dists, duplicates
else:
return [cluster_vector], [strengths], [0.0], nn_inds, nn_dists
else:
cluster_layers, membership_strengths, persistence_scores = build_cluster_layers(
embedding,
min_samples=min_samples,
base_min_cluster_size=base_min_cluster_size,
base_n_clusters=base_n_clusters,
reproducible_flag=reproducible_flag,
min_similarity_threshold=min_similarity_threshold,
max_layers=max_layers,
)
if return_duplicates:
return (
cluster_layers,
membership_strengths,
persistence_scores,
nn_inds,
nn_dists,
duplicates,
)
else:
return (
cluster_layers,
membership_strengths,
persistence_scores,
nn_inds,
nn_dists,
)
class EVoC(BaseEstimator, ClusterMixin):
"""
Embedding Vector Oriented Clustering for efficient clustering of high-dimensional
embedding vectors such as CLIP-vectors, sentence-transformers output, etc. The
clustering uses a combination of a node embedding of a nearest neighbour graph,
related to UMAP, and a density based clustering approach related to HDBSCAN,
improving upon those approaches in efficiency and quality for the specific case
of high-dimensional embedding vectors.
Parameters
----------
noise_level : float, default=0.5
The noise level expected in the data. A value of 0.0 will try to cluster
more data, at the expense of getting less accurate clustering. A value of
1.0 will try for accurate clusters, discarding more data as noise to do so.
base_min_cluster_size : int, default=5
The minimum number of points in a cluster at the base layer of the clustering.
This gives the finest granularity clustering that will be returned, with less
graularity at higher layers.
base_n_clusters : int or None, default=None
If not None, the algorithm will attempt to find the granularity of
clustering that will give exactly this many clusters for the bottom-most layer
of clustering. This affects the base layer computation and allows multiple
layers to be built on top of this base. Since the actual number of clusters
cannot be guaranteed this is only approximate, but usually the algorithm can
manage to get this exact number, assuming a reasonable clustering into
``base_n_clusters`` exists.
approx_n_clusters : int, default=None
If not None, the algorithm will attempt to find the granularity of
clustering that will give exactly this many clusters as the final output.
Unlike ``base_n_clusters``, when this parameter is set, only a single
clustering layer will be returned -- no hierarchical layers will be produced.
This is useful when you know the exact number of clusters you want and don't
need the multi-layer analysis. Since the actual number of clusters cannot be
guaranteed this is only approximate, but usually the algorithm can manage to
get this exact number, assuming a reasonable clustering into ``approx_n_clusters``
exists.
n_neighbors : int, default=15
The number of neighbors to use in the nearest neighbor graph construction.
min_samples : int, default=5
The minimum number of samples to use in the density estimation when
performing density based clustering on the node embedding.
n_epochs : int, default=50
The number of epochs to use when training the node embedding.
node_embedding_init : str or None, default='label_prop'
The method to use to initialize the node embedding. If None, no initialization
will be used. If 'label_prop', the label propagation method will be used.
symmetrize_graph : bool, default=True
Whether to symmetrize the nearest neighbor graph before using it to
construct the node embedding.
node_embedding_dim : int or None, default=None
The number of dimensions to use in the node embedding. If None, a default
value of min(max(n_neighbors // 4, 4), 15) will be used.
neighbor_scale : float, default=1.0
The scale factor to use when constructing the nearest neighbor graph. This
multiplies the effective number of neighbors used in graph construction
(neighbor_scale * n_neighbors). Values > 1.0 create denser graphs with more
connectivity, potentially capturing more global structure but at increased
computational cost. Values < 1.0 create sparser graphs focused on local
structure.
random_state : int or None, default=None
The random seed to use for the random number generator. If None, the random
number generator will not be seeded and will use the system time as the seed.
min_similarity_threshold : float, default=0.2
The minimum similarity threshold for cluster layer selection. Peaks that result
in clusterings with Jaccard similarity above this threshold will be filtered out
to ensure diverse cluster layers.
max_layers : int, default=10
The maximum number of cluster layers to return. The algorithm will select up to
this many diverse peaks based on persistence and similarity criteria.
n_label_prop_iter : int, default=20
The number of iterations to use in the label propagation algorithm when
initializing the node embedding. This parameter controls how many steps
the label propagation process takes to converge when node_embedding_init
is set to 'label_prop'.
Attributes
----------
labels_ : array-like of shape (n_samples,)
An array of labels for the data samples; this is a integer array as per other scikit-learn
clustering algorithms. A value of -1 indicates that a point is a noise point and
not in any cluster.
membership_strengths_ : array-like of shape (n_samples,)
An array of membership strengths for the data samples; this gives a measure of how
strongly each point belongs to each cluster. This is a floating point array with
values between 0 and 1.
cluster_layers_ : list of array-like of shape (n_samples,)
The clustering of the data at each layer of the clustering. Each layer
is a clustering of the data into a different number of clusters; the earlier the
cluster vector is in this list the finer the granularity of clustering.
membership_strength_layers_ : list of array-like of shape (n_samples,)
The membership strengths of each point in the clustering at each layer.
cluster_tree_ : dict
A dictionary representing the hierarchical clustering of the data. The keys are
tuples of (layer, cluster) and the values are lists of tuples of (layer, cluster)
representing the children of the key cluster.
nn_inds_ : array-like of shape (n_samples, n_neighbors)
Indices of nearest neighbors for each sample.
nn_dists_ : array-like of shape (n_samples, n_neighbors)
Distance from each sample to each nearest neighbor (indexed by nn_inds).
duplicates_ : set of tuple of int
A set of pairs of indices of potential duplicate points in the data.
"""
def __init__(
self,
noise_level: float = 0.5,
base_min_cluster_size: int = 5,
base_n_clusters: int | None = None,
approx_n_clusters: int | None = None,
n_neighbors: int = 15,
min_samples: int = 5,
n_epochs: int = 50,
node_embedding_init: str | None = "label_prop",
symmetrize_graph: bool = True,
node_embedding_dim: int | None = None,
neighbor_scale: float = 1.0,
random_state: int | None = None,
min_similarity_threshold: float = 0.2,
max_layers: int = 10,
n_label_prop_iter=20,
) -> None:
self.n_neighbors = n_neighbors
self.noise_level = noise_level
self.base_min_cluster_size = base_min_cluster_size
self.base_n_clusters = base_n_clusters
self.approx_n_clusters = approx_n_clusters
self.min_samples = min_samples
self.n_epochs = n_epochs
self.node_embedding_init = node_embedding_init
self.symmetrize_graph = symmetrize_graph
self.node_embedding_dim = node_embedding_dim
self.neighbor_scale = neighbor_scale
self.random_state = random_state
self.min_similarity_threshold = min_similarity_threshold
self.max_layers = max_layers
self.n_label_prop_iter = n_label_prop_iter
def fit_predict(self, X, y=None, **fit_params):
"""Fit the model to the data and return the clustering labels.
Parameters
----------
X : array-like of shape (n_samples, n_features)
The data to cluster. If the data is float valued then it is assumed to use
cosine distance as a matric. If the data is int8 valued then it is assumed
that a quantized embedding is being used and a quantized version of cosine
distance is used. If the data is uint8 valued then it is assumed that a
binary embedding is being used, and a bitwise Jaccard distance is used.
y : array-like of shape (n_samples,), default=None
Ignored. This parameter exists only for compatibility with
scikit-learn's fit_predict method.
**fit_params : dict
Additional fit parameters. Currently unused, included for compatibility
with scikit-learn's fit_predict interface.
Returns
-------
labels_ : array-like of shape (n_samples,)
An array of labels for the data samples; this is a integer array as per other scikit-learn
clustering algorithms. A value of -1 indicates that a point is a noise point and
not in any cluster.
"""
X = check_array(X)
current_random_state = check_random_state(self.random_state)
(
self.cluster_layers_,
self.membership_strength_layers_,
self.persistence_scores_,
self.nn_inds_,
self.nn_dists_,
self.duplicates_,
) = evoc_clusters(
X,
n_neighbors=self.n_neighbors,
noise_level=self.noise_level,
base_min_cluster_size=self.base_min_cluster_size,
base_n_clusters=self.base_n_clusters,
approx_n_clusters=self.approx_n_clusters,
min_samples=self.min_samples,
n_epochs=self.n_epochs,
node_embedding_init=self.node_embedding_init,
symmetrize_graph=self.symmetrize_graph,
return_duplicates=True,
node_embedding_dim=self.node_embedding_dim,
neighbor_scale=self.neighbor_scale,
random_state=current_random_state,
reproducible_flag=self.random_state is not None,
min_similarity_threshold=self.min_similarity_threshold,
max_layers=self.max_layers,
n_label_prop_iter=self.n_label_prop_iter,
)
if len(self.cluster_layers_) == 1:
self.labels_ = self.cluster_layers_[0]
self.membership_strengths_ = self.membership_strength_layers_[0]
else:
best_layer = np.argmax(self.persistence_scores_)
self.labels_ = self.cluster_layers_[best_layer]
self.membership_strengths_ = self.membership_strength_layers_[best_layer]
return self.labels_
def fit(self, X, y=None, **fit_params):
"""Fit the model to the data.
Parameters
----------
X : array-like of shape (n_samples, n_features)
The data to cluster. If the data is float valued then it is assumed to use
cosine distance as a matric. If the data is int8 valued then it is assumed
that a quantized embedding is being used and a quantized version of cosine
distance is used. If the data is uint8 valued then it is assumed that a
binary embedding is being used, and a bitwise Jaccard distance is used.
y : array-like of shape (n_samples,), default=None
Ignored. This parameter exists only for compatibility with
scikit-learn's fit method.
**fit_params : dict
Additional fit parameters. Currently unused, included for compatibility
with scikit-learn's fit interface.
Returns
-------
self : sklearn Estimator
Returns the instance itself.
"""
self.fit_predict(X, y, **fit_params)
return self
@property
def cluster_tree_(self):
"""dict
A dictionary representing the hierarchical clustering of the data.
The keys are tuples of (layer, cluster) and the values are lists of
tuples of (layer, cluster) representing the children of the key cluster.
This provides a tree structure showing how clusters at different layers
relate to each other hierarchically.
Only available after fitting the model.
Returns
-------
dict
Hierarchical tree structure with (layer, cluster) tuples as keys
and lists of child (layer, cluster) tuples as values.
Raises
------
NotFittedError
If the model has not been fitted yet.
"""
check_is_fitted(
self,
"cluster_layers_",
msg="This %(name)s instance is not fitted yet, and 'cluster_tree_' is not available. "
"Please call 'fit' with appropriate arguments before accessing this attribute.",
)
if not hasattr(self, "_cluster_tree"):
self._cluster_tree = build_cluster_tree(self.cluster_layers_)
return self._cluster_tree
================================================
FILE: evoc/clustering_utilities.py
================================================
import numpy as np
import numba
from .numba_kdtree import build_kdtree
from .boruvka import parallel_boruvka
from .cluster_trees import (
mst_to_linkage_tree,
condense_tree,
extract_leaves,
get_cluster_label_vector,
get_point_membership_strength_vector,
)
##############################################################
# Directly derived from scipy's find_peaks function:
# https://github.com/scipy/scipy/blob/bd66693b8aecc6f528ca9b1cfd6bb1f61477ca0f/scipy/signal/_peak_finding_utils.pyx#L20
##############################################################
@numba.njit(
["intp[:](float32[::1])", "intp[:](float64[::1])"],
locals={
"midpoints": numba.types.intp[::1],
"left_edges": numba.types.intp[::1],
"right_edges": numba.types.intp[::1],
"m": numba.types.uint32,
"i": numba.types.uint32,
},
nogil=True,
parallel=False,
fastmath=True,
cache=True,
)
def find_peaks(x):
# Preallocate, there can't be more maxima than half the size of `x`
midpoints = np.empty(x.shape[0] // 2, dtype=np.intp)
left_edges = np.empty(x.shape[0] // 2, dtype=np.intp)
right_edges = np.empty(x.shape[0] // 2, dtype=np.intp)
m = 0 # Pointer to the end of valid area in allocated arrays
i = 1 # Pointer to current sample, first one can't be maxima
i_max = x.shape[0] - 1 # Last sample can't be maxima
while i < i_max:
# Test if previous sample is smaller
if x[i - 1] < x[i]:
i_ahead = i + 1 # Index to look ahead of current sample
# Find next sample that is unequal to x[i]
while i_ahead < i_max and x[i_ahead] == x[i]:
i_ahead += 1
# Maxima is found if next unequal sample is smaller than x[i]
if x[i_ahead] < x[i]:
left_edges[m] = i
right_edges[m] = i_ahead - 1
midpoints[m] = (left_edges[m] + right_edges[m]) // 2
m += 1
# Skip samples that can't be maximum
i = i_ahead
i += 1
return midpoints[:m]
@numba.njit(cache=True)
def _binary_search_for_n_clusters(uncondensed_tree, approx_n_clusters, n_samples):
lower_bound_min_cluster_size = 2
upper_bound_min_cluster_size = n_samples // 2
mid_min_cluster_size = int(
round((lower_bound_min_cluster_size + upper_bound_min_cluster_size) / 2.0)
)
min_n_clusters = 0
upper_tree = condense_tree(uncondensed_tree, upper_bound_min_cluster_size)
leaves = extract_leaves(upper_tree)
upper_n_clusters = len(leaves)
lower_tree = condense_tree(uncondensed_tree, lower_bound_min_cluster_size)
leaves = extract_leaves(lower_tree)
lower_n_clusters = len(leaves)
while upper_bound_min_cluster_size - lower_bound_min_cluster_size > 1:
mid_min_cluster_size = int(
round((lower_bound_min_cluster_size + upper_bound_min_cluster_size) / 2.0)
)
if (
mid_min_cluster_size == lower_bound_min_cluster_size
or mid_min_cluster_size == upper_bound_min_cluster_size
):
break
mid_tree = condense_tree(uncondensed_tree, mid_min_cluster_size)
leaves = extract_leaves(mid_tree)
mid_n_clusters = len(leaves)
if mid_n_clusters < approx_n_clusters:
upper_bound_min_cluster_size = mid_min_cluster_size
upper_n_clusters = mid_n_clusters
elif mid_n_clusters >= approx_n_clusters:
lower_bound_min_cluster_size = mid_min_cluster_size
lower_n_clusters = mid_n_clusters
if abs(lower_n_clusters - approx_n_clusters) < abs(
upper_n_clusters - approx_n_clusters
):
lower_tree = condense_tree(uncondensed_tree, lower_bound_min_cluster_size)
leaves = extract_leaves(lower_tree)
clusters = get_cluster_label_vector(lower_tree, leaves, 0.0, n_samples)
strengths = get_point_membership_strength_vector(lower_tree, leaves, clusters)
return leaves, clusters, strengths
elif abs(lower_n_clusters - approx_n_clusters) > abs(
upper_n_clusters - approx_n_clusters
):
upper_tree = condense_tree(uncondensed_tree, upper_bound_min_cluster_size)
leaves = extract_leaves(upper_tree)
clusters = get_cluster_label_vector(upper_tree, leaves, 0.0, n_samples)
strengths = get_point_membership_strength_vector(upper_tree, leaves, clusters)
return leaves, clusters, strengths
else:
lower_tree = condense_tree(uncondensed_tree, lower_bound_min_cluster_size)
lower_leaves = extract_leaves(lower_tree)
lower_clusters = get_cluster_label_vector(
lower_tree, lower_leaves, 0.0, n_samples
)
upper_tree = condense_tree(uncondensed_tree, upper_bound_min_cluster_size)
upper_leaves = extract_leaves(upper_tree)
upper_clusters = get_cluster_label_vector(
upper_tree, upper_leaves, 0.0, n_samples
)
if np.sum(lower_clusters >= 0) > np.sum(upper_clusters >= 0):
strengths = get_point_membership_strength_vector(
lower_tree, lower_leaves, lower_clusters
)
return lower_leaves, lower_clusters, strengths
else:
strengths = get_point_membership_strength_vector(
upper_tree, upper_leaves, upper_clusters
)
return upper_leaves, upper_clusters, strengths
# @numba.njit(cache=True)
def binary_search_for_n_clusters(
data,
approx_n_clusters,
n_threads,
*,
min_samples=5,
):
numba_tree = build_kdtree(data.astype(np.float32))
edges = parallel_boruvka(
numba_tree, n_threads, min_samples=min_samples, reproducible=False
)
sorted_mst = edges[np.argsort(edges.T[2])]
uncondensed_tree = mst_to_linkage_tree(sorted_mst)
n_samples = data.shape[0]
leaves, clusters, strengths = _binary_search_for_n_clusters(
uncondensed_tree, approx_n_clusters, n_samples
)
return clusters, strengths
@numba.njit(cache=True)
def min_cluster_size_barcode(cluster_tree, n_points, min_size):
n_nodes = cluster_tree.child[-1] - n_points + 1
parents = np.empty(n_nodes, dtype=np.int32)
lambda_deaths = np.empty(n_nodes, dtype=np.float32)
size_deaths = np.empty(n_nodes, dtype=np.float32)
size_births = np.full(n_nodes, min_size, dtype=np.float32)
lambda_deaths[0] = 0
size_deaths[0] = n_points
parents[0] = n_points
# Iterate over row-pairs in reverse order
n_rows = cluster_tree.child.shape[0]
for idx in range(n_rows - 1, 0, -2):
out_idx = cluster_tree.child[idx] - n_points
parents[out_idx - 1 : out_idx + 1] = cluster_tree.parent[idx]
lambda_deaths[out_idx - 1 : out_idx + 1] = np.exp(
-1 / cluster_tree.lambda_val[idx]
)
death_size = cluster_tree.child_size[idx - 1 : idx + 1].min()
size_deaths[out_idx - 1 : out_idx + 1] = death_size
size_births[cluster_tree.parent[idx] - n_points] = max(
size_births[out_idx - 1], size_births[out_idx], death_size
)
return size_births, size_deaths, parents, lambda_deaths
@numba.njit(cache=True)
def compute_total_persistence(births, deaths, lambda_deaths):
# maintain left-open (birth, death] interval!
sizes = np.unique(births)
total_persistence = np.zeros(sizes.shape[0], dtype=np.float32)
for i in range(1, len(births)):
birth = births[i]
death = deaths[i]
lambda_death = lambda_deaths[i]
if death <= birth:
continue
# Manual binary search for birth_idx
birth_idx = 0
for j in range(len(sizes)):
if sizes[j] >= birth:
birth_idx = j
break
# Manual binary search for death_idx
death_idx = len(sizes)
for j in range(len(sizes)):
if sizes[j] >= death:
death_idx = j
break
# Update persistence values
for k in range(birth_idx, death_idx):
total_persistence[k] += (death - birth) * lambda_death
return sizes, total_persistence
@numba.njit(cache=True)
def extract_clusters_by_id(condensed_tree, selected_ids):
labels = get_cluster_label_vector(
condensed_tree,
selected_ids,
cluster_selection_epsilon=0.0,
n_samples=condensed_tree.parent[0],
)
strengths = get_point_membership_strength_vector(
condensed_tree, selected_ids, labels
)
return labels, strengths
@numba.njit(cache=True)
def jaccard_similarity(set_a_array, set_b_array):
# Convert to sets for intersection/union operations
intersection_count = 0
union_set = set(set_a_array)
for item in set_b_array:
if item in union_set:
intersection_count += 1
else:
union_set.add(item)
union_count = len(union_set)
return intersection_count / union_count if union_count > 0 else 0.0
@numba.njit(cache=True)
def estimate_cluster_similarity(births, deaths, birth_a, birth_b):
# Find clusters active at birth_a
clusters_a = np.empty(len(births), dtype=np.int64)
count_a = 0
for i in range(len(births)):
if births[i] <= birth_a and deaths[i] > birth_a:
clusters_a[count_a] = i
count_a += 1
# Find clusters active at birth_b
clusters_b = np.empty(len(births), dtype=np.int64)
count_b = 0
for i in range(len(births)):
if births[i] <= birth_b and deaths[i] > birth_b:
clusters_b[count_b] = i
count_b += 1
# Trim arrays to actual sizes
active_a = clusters_a[:count_a]
active_b = clusters_b[:count_b]
return jaccard_similarity(active_a, active_b)
@numba.njit(cache=True)
def select_diverse_peaks(
peaks,
total_persistence,
sizes,
births,
deaths,
min_similarity_threshold=0.2,
max_layers=10,
):
if len(peaks) == 0:
return np.empty(0, dtype=np.int64)
# Sort peaks by persistence (highest first)
peak_persistence = total_persistence[peaks]
sorted_indices = np.argsort(peak_persistence)[::-1]
sorted_peaks = peaks[sorted_indices]
# Pre-allocate arrays for selected peaks and births
selected_peaks = np.empty(max_layers, dtype=np.int64)
selected_births = np.empty(max_layers, dtype=np.float64)
n_selected = 0
for i in range(len(sorted_peaks)):
if n_selected >= max_layers:
break
peak = sorted_peaks[i]
birth_size = sizes[peak]
# Check similarity with already selected peaks
is_diverse = True
for j in range(n_selected):
selected_birth = selected_births[j]
similarity = estimate_cluster_similarity(
births, deaths, birth_size, selected_birth
)
if similarity > min_similarity_threshold:
is_diverse = False
break
if is_diverse:
selected_peaks[n_selected] = peak
selected_births[n_selected] = birth_size
n_selected += 1
return selected_peaks[:n_selected]
@numba.njit(cache=True)
def _build_cluster_tree(labels):
mapping = [(-1, -1, -1, -1) for i in range(0)]
found = [set([-1]) for i in range(len(labels))]
mapping_idx = 0
for upper_layer in range(1, len(labels)):
upper_layer_unique_labels = np.unique(labels[upper_layer])
for lower_layer in range(upper_layer - 1, -1, -1):
upper_cluster_order = np.argsort(labels[upper_layer])
cluster_groups = np.split(
labels[lower_layer][upper_cluster_order],
np.cumsum(np.bincount(labels[upper_layer] + 1))[:-1],
)
for i, label in enumerate(upper_layer_unique_labels):
if label >= 0:
for child in cluster_groups[i]:
if child >= 0 and child not in found[lower_layer]:
mapping.append((upper_layer, label, lower_layer, child))
found[lower_layer].add(child)
for lower_layer in range(len(labels) - 1, -1, -1):
for child in range(labels[lower_layer].max() + 1):
if child >= 0 and child not in found[lower_layer]:
mapping.append((len(labels), 0, lower_layer, child))
return mapping
def build_cluster_tree(labels):
result = {}
raw_mapping = _build_cluster_tree(labels)
for parent_layer, parent_cluster, child_layer, child_cluster in raw_mapping:
parent_name = (parent_layer, parent_cluster)
if parent_name in result:
result[parent_name].append((child_layer, child_cluster))
else:
result[parent_name] = [(child_layer, child_cluster)]
return result
@numba.njit(cache=True)
def find_duplicates(knn_inds, knn_dists):
duplicate_distance = np.max(knn_dists.T[0])
duplicates = set([(-1, -1) for i in range(0)])
for i in range(knn_inds.shape[0]):
for j in range(0, knn_inds.shape[1]):
if knn_dists[i, j] <= duplicate_distance:
k = knn_inds[i, j]
if i < k:
duplicates.add((i, k))
elif k < i:
duplicates.add((k, i))
else:
continue
return duplicates
================================================
FILE: evoc/common_nndescent.py
================================================
import numpy as np
import numba
@numba.njit("void(i8[:], i8)", cache=True)
def seed(rng_state, seed):
"""Seed the random number generator with a given seed."""
rng_state.fill(seed + 0xFFFF)
@numba.njit("i4(i8[:])", cache=True)
def tau_rand_int(state):
"""A fast (pseudo)-random number generator.
Parameters
----------
state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
A (pseudo)-random int32 value
"""
state[0] = (((state[0] & 4294967294) << 12) & 0xFFFFFFFF) ^ (
(((state[0] << 13) & 0xFFFFFFFF) ^ state[0]) >> 19
)
state[1] = (((state[1] & 4294967288) << 4) & 0xFFFFFFFF) ^ (
(((state[1] << 2) & 0xFFFFFFFF) ^ state[1]) >> 25
)
state[2] = (((state[2] & 4294967280) << 17) & 0xFFFFFFFF) ^ (
(((state[2] << 3) & 0xFFFFFFFF) ^ state[2]) >> 11
)
return state[0] ^ state[1] ^ state[2]
@numba.njit("f4(i8[:])", cache=True)
def tau_rand(state):
"""A fast (pseudo)-random number generator for floats in the range [0,1]
Parameters
----------
state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
A (pseudo)-random float32 in the interval [0, 1]
"""
integer = tau_rand_int(state)
return abs(float(integer) / 0x7FFFFFFF)
# @numba.njit(cache=True)
def make_heap(n_points, size):
indices = np.full((int(n_points), int(size)), -1, dtype=np.int32)
distances = np.full((int(n_points), int(size)), np.inf, dtype=np.float32)
flags = np.zeros((int(n_points), int(size)), dtype=np.uint8)
result = (indices, distances, flags)
return result
@numba.njit(cache=True)
def siftdown(heap1, heap2, elt):
"""Restore the heap property for a heap with an out of place element
at position ``elt``. This works with a heap pair where heap1 carries
the weights and heap2 holds the corresponding elements."""
while elt * 2 + 1 < heap1.shape[0]:
left_child = elt * 2 + 1
right_child = left_child + 1
swap = elt
if heap1[swap] < heap1[left_child]:
swap = left_child
if right_child < heap1.shape[0] and heap1[swap] < heap1[right_child]:
swap = right_child
if swap == elt:
break
else:
heap1[elt], heap1[swap] = heap1[swap], heap1[elt]
heap2[elt], heap2[swap] = heap2[swap], heap2[elt]
elt = swap
@numba.njit(parallel=True, cache=True)
def deheap_sort(indices, distances):
"""Given two arrays representing a heap (indices and distances), reorder the
arrays by increasing distance. This is effectively just the second half of
heap sort (the first half not being required since we already have the
graph_data in a heap).
Note that this is done in-place.
Parameters
----------
indices : array of shape (n_samples, n_neighbors)
The graph indices to sort by distance.
distances : array of shape (n_samples, n_neighbors)
The corresponding edge distance.
Returns
-------
indices, distances: arrays of shape (n_samples, n_neighbors)
The indices and distances sorted by increasing distance.
"""
for i in numba.prange(indices.shape[0]):
# starting from the end of the array and moving back
for j in range(indices.shape[1] - 1, 0, -1):
indices[i, 0], indices[i, j] = indices[i, j], indices[i, 0]
distances[i, 0], distances[i, j] = distances[i, j], distances[i, 0]
siftdown(distances[i, :j], indices[i, :j], 0)
return indices, distances
@numba.njit(
"i4(f4[::1],i4[::1],f4,i4)",
fastmath=True,
locals={
"size": numba.types.intp,
"i": numba.types.uint16,
"ic1": numba.types.uint16,
"ic2": numba.types.uint16,
"i_swap": numba.types.uint16,
},
cache=True,
)
def build_candidates_heap_push(priorities, indices, p, n):
if p >= priorities[0]:
return 0
size = priorities.shape[0]
# break if we already have this element.
for i in range(size):
if n == indices[i]:
return 0
# insert val at position zero
priorities[0] = p
indices[0] = n
# descend the heap, swapping values until the max heap criterion is met
i = 0
while True:
ic1 = 2 * i + 1
ic2 = ic1 + 1
if ic1 >= size:
break
elif ic2 >= size:
if priorities[ic1] > p:
i_swap = ic1
else:
break
elif priorities[ic1] >= priorities[ic2]:
if p < priorities[ic1]:
i_swap = ic1
else:
break
else:
if p < priorities[ic2]:
i_swap = ic2
else:
break
priorities[i] = priorities[i_swap]
indices[i] = indices[i_swap]
i = i_swap
priorities[i] = p
indices[i] = n
return 1
@numba.njit(parallel=True, locals={"idx": numba.types.int64}, cache=True)
def build_candidates(current_graph, max_candidates, rng_state, n_threads):
"""Build a heap of candidate neighbors for nearest neighbor descent. For
each vertex the candidate neighbors are any current neighbors, and any
vertices that have the vertex as one of their nearest neighbors.
Parameters
----------
current_graph: heap
The current state of the graph for nearest neighbor descent.
max_candidates: int
The maximum number of new candidate neighbors.
rng_state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
candidate_neighbors: A heap with an array of (randomly sorted) candidate
neighbors for each vertex in the graph.
"""
current_indices = current_graph[0]
current_flags = current_graph[2]
n_vertices = current_indices.shape[0]
n_neighbors = current_indices.shape[1]
new_candidate_indices = np.full((n_vertices, max_candidates), -1, dtype=np.int32)
new_candidate_priority = np.full(
(n_vertices, max_candidates), np.inf, dtype=np.float32
)
old_candidate_indices = np.full((n_vertices, max_candidates), -1, dtype=np.int32)
old_candidate_priority = np.full(
(n_vertices, max_candidates), np.inf, dtype=np.float32
)
block_size = n_vertices // n_threads + 1
for n in numba.prange(n_threads):
local_rng_state = rng_state + n
block_start = n * block_size
block_end = min(block_start + block_size, n_vertices)
for i in range(n_vertices):
for j in range(n_neighbors):
idx = current_indices[i, j]
if idx >= 0 and (
(i >= block_start and i < block_end)
or (idx >= block_start and idx < block_end)
):
isn = current_flags[i, j]
d = tau_rand(local_rng_state)
if isn:
if i >= block_start and i < block_end:
build_candidates_heap_push(
new_candidate_priority[i],
new_candidate_indices[i],
d,
idx,
)
if idx >= block_start and idx < block_end:
build_candidates_heap_push(
new_candidate_priority[idx],
new_candidate_indices[idx],
d,
i,
)
else:
if i >= block_start and i < block_end:
build_candidates_heap_push(
old_candidate_priority[i],
old_candidate_indices[i],
d,
idx,
)
if idx >= block_start and idx < block_end:
build_candidates_heap_push(
old_candidate_priority[idx],
old_candidate_indices[idx],
d,
i,
)
indices = current_graph[0]
flags = current_graph[2]
for i in numba.prange(n_vertices):
for j in range(n_neighbors):
idx = indices[i, j]
for k in range(max_candidates):
if new_candidate_indices[i, k] == idx:
flags[i, j] = 0
break
return new_candidate_indices, old_candidate_indices
@numba.njit(
"i4(f4[::1],i4[::1],u1[::1],f4,i4)",
fastmath=True,
locals={
"size": numba.types.intp,
"i": numba.types.uint16,
"ic1": numba.types.uint16,
"ic2": numba.types.uint16,
"i_swap": numba.types.uint16,
},
cache=True,
)
def flagged_heap_push(priorities, indices, flags, p, n):
if p >= priorities[0]:
return 0
size = priorities.shape[0]
# break if we already have this element.
for i in range(size):
if n == indices[i]:
return 0
# insert val at position zero
priorities[0] = p
indices[0] = n
# descend the heap, swapping values until the max heap criterion is met
i = 0
while True:
ic1 = 2 * i + 1
ic2 = ic1 + 1
if ic1 >= size:
break
elif ic2 >= size:
if priorities[ic1] > p:
i_swap = ic1
else:
break
elif priorities[ic1] >= priorities[ic2]:
if p < priorities[ic1]:
i_swap = ic1
else:
break
else:
if p < priorities[ic2]:
i_swap = ic2
else:
break
priorities[i] = priorities[i_swap]
indices[i] = indices[i_swap]
flags[i] = flags[i_swap]
i = i_swap
priorities[i] = p
indices[i] = n
flags[i] = 1
return 1
@numba.njit(
numba.uint32(
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.float32[:, :, ::1],
numba.int32[::1],
numba.int64,
),
parallel=True,
locals={
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"added": numba.uint8,
"n": numba.uint32,
"i": numba.uint32,
"j": numba.uint32,
"priorities": numba.float32[:, ::1],
"indices": numba.int32[:, ::1],
"flags": numba.uint8[:, ::1],
},
cache=True,
)
def apply_graph_update_array(
current_graph, update_array, n_updates_per_thread, n_threads
):
n_changes = 0
priorities = current_graph[1]
indices = current_graph[0]
flags = current_graph[2]
n_vertices = priorities.shape[0]
block_size = n_vertices // n_threads + 1
for n in numba.prange(n_threads):
block_start = n * block_size
block_end = min(block_start + block_size, n_vertices)
for i in range(update_array.shape[0]):
for j in range(n_updates_per_thread[i]):
p = np.int32(update_array[i, j, 0])
if p == -1:
break
q = np.int32(update_array[i, j, 1])
d = np.float32(update_array[i, j, 2])
if p >= block_start and p < block_end:
added = flagged_heap_push(priorities[p], indices[p], flags[p], d, q)
n_changes += added
if q >= block_start and q < block_end:
added = flagged_heap_push(priorities[q], indices[q], flags[q], d, p)
n_changes += added
return n_changes
@numba.njit(
numba.uint32(
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.float32[:, :, ::1],
numba.int32[:, ::1],
numba.int64,
),
parallel=True,
cache=True,
locals={
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"added": numba.uint8,
"n": numba.uint32,
"t": numba.uint32,
"j": numba.uint32,
"priorities": numba.float32[:, ::1],
"indices": numba.int32[:, ::1],
"flags": numba.uint8[:, ::1],
},
)
def apply_sorted_graph_updates(
current_graph, update_array, n_updates_per_block, n_threads
):
"""
Apply pre-sorted graph updates where updates are bucketed by target block.
Each thread processes only its own bucket, avoiding the need to scan all updates.
This provides O(updates_per_block) work per thread instead of O(total_updates).
"""
n_changes = 0
priorities = current_graph[1]
indices = current_graph[0]
flags = current_graph[2]
n_vertices = priorities.shape[0]
vertex_block_size = n_vertices // n_threads + 1
max_updates_per_thread = update_array.shape[1] // n_threads
for n in numba.prange(n_threads):
block_start = n * vertex_block_size
block_end = min(block_start + vertex_block_size, n_vertices)
# Process all updates in this block's bucket
# Updates were written by each thread at offset t * max_updates_per_thread
for t in range(n_threads):
thread_start = t * max_updates_per_thread
thread_count = n_updates_per_block[n, t + 1]
for j in range(thread_count):
idx = thread_start + j
p = np.int32(update_array[n, idx, 0])
q = np.int32(update_array[n, idx, 1])
d = np.float32(update_array[n, idx, 2])
# Apply update to p if it's in this block
if p >= block_start and p < block_end:
added = flagged_heap_push(priorities[p], indices[p], flags[p], d, q)
n_changes += added
# Apply update to q if it's in this block
if q >= block_start and q < block_end:
added = flagged_heap_push(priorities[q], indices[q], flags[q], d, p)
n_changes += added
return n_changes
================================================
FILE: evoc/disjoint_set.py
================================================
import numba
import numpy as np
from collections import namedtuple
RankDisjointSet = namedtuple("RankDisjointSet", ["parent", "rank"])
SizeDisjointSet = namedtuple("SizeDisjointSet", ["parent", "size"])
_sentinel_rank_ds = RankDisjointSet(
parent=np.empty(1, dtype=np.int32),
rank=np.empty(1, dtype=np.int32),
)
_sentinel_size_ds = SizeDisjointSet(
parent=np.empty(1, dtype=np.int32),
size=np.empty(1, dtype=np.int32),
)
RankDisjointSetType = numba.typeof(_sentinel_rank_ds)
SizeDisjointSetType = numba.typeof(_sentinel_size_ds)
@numba.njit(cache=True)
def ds_rank_create(n_elements):
return RankDisjointSet(
np.arange(n_elements, dtype=np.int32), np.zeros(n_elements, dtype=np.int32)
)
@numba.njit(cache=True)
def ds_size_create(n_elements):
return SizeDisjointSet(
np.arange(n_elements, dtype=np.int32), np.ones(n_elements, dtype=np.int32)
)
@numba.njit(cache=True)
def ds_find(disjoint_set, x):
while disjoint_set.parent[x] != x:
x, disjoint_set.parent[x] = (
disjoint_set.parent[x],
disjoint_set.parent[disjoint_set.parent[x]],
)
return x
@numba.njit(
numba.void(
RankDisjointSetType,
numba.int32,
numba.int32,
),
cache=True,
)
def ds_union_by_rank(disjoint_set, x, y):
x = ds_find(disjoint_set, x)
y = ds_find(disjoint_set, y)
if x == y:
return
if disjoint_set.rank[x] < disjoint_set.rank[y]:
x, y = y, x
disjoint_set.parent[y] = x
if disjoint_set.rank[x] == disjoint_set.rank[y]:
disjoint_set.rank[x] += 1
@numba.njit(
numba.void(
SizeDisjointSetType,
numba.int32,
numba.int32,
),
cache=True,
)
def ds_union_by_size(disjoint_set, x, y):
x = ds_find(disjoint_set, x)
y = ds_find(disjoint_set, y)
if x == y:
return
if disjoint_set.size[x] < disjoint_set.size[y]:
x, y = y, x
disjoint_set.parent[y] = x
disjoint_set.size[x] += disjoint_set.size[y]
================================================
FILE: evoc/float_nndescent.py
================================================
import numba
import numpy as np
from .common_nndescent import (
tau_rand_int,
make_heap,
deheap_sort,
flagged_heap_push,
build_candidates,
apply_graph_update_array,
apply_sorted_graph_updates,
)
from .nested_parallelism import ENABLE_NESTED_PARALLELISM
# Used for a floating point "nearly zero" comparison
EPS = 1e-8
INF = np.finfo(np.float32).max
EXP_NEG_INF = np.finfo(np.float32).tiny
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
point_indices_type = numba.int32[::1]
@numba.njit(
[
"f4(f4[::1],f4[::1])",
numba.types.float32(
numba.types.Array(numba.types.float32, 1, "C", readonly=True),
numba.types.Array(numba.types.float32, 1, "C", readonly=True),
),
],
fastmath=True,
locals={
"result": numba.types.float32,
"dim": numba.types.intp,
"i": numba.types.uint16,
},
boundscheck=False,
nogil=True,
cache=True,
)
def fast_cosine(x, y):
"""
Calculates the cosine similarity between two vectors.
Args:
x (numpy.ndarray): The first vector.
y (numpy.ndarray): The second vector.
Returns:
float: The cosine similarity between x and y.
"""
result = 0.0
dim = x.shape[0]
for i in range(dim):
result += x[i] * y[i]
if result > 0.0:
return -result
else:
return -EXP_NEG_INF
@numba.njit(
numba.types.Tuple((numba.int32[::1], numba.int32[::1]))(
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int32[::1],
numba.int64[::1],
),
locals={
"n_left": numba.uint64,
"n_right": numba.uint64,
"left_data": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"right_data": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"test_data": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"hyperplane_vector": numba.float32[::1],
"hyperplane_norm": numba.float32,
"margin": numba.float32,
"d": numba.uint32,
"left_index": numba.uint32,
"right_index": numba.uint32,
"point_idx": numba.int32,
"classification": numba.int8,
"max_size": numba.uint32,
"temp_left": numba.int32[::1],
"temp_right": numba.int32[::1],
"indices_size": numba.int32,
},
fastmath=True,
nogil=True,
cache=True,
boundscheck=False,
)
def float_random_projection_split(data, indices, rng_state):
"""Given a set of ``graph_indices`` for graph_data points from ``graph_data``, create
a random hyperplane to split the graph_data, returning two arrays graph_indices
that fall on either side of the hyperplane. This is the basis for a
random projection tree, which simply uses this splitting recursively.
This particular split uses cosine distance to determine the hyperplane
and which side each graph_data sample falls on.
Parameters
----------
data: array of shape (n_samples, n_features)
The original graph_data to be split
indices: array of shape (tree_node_size,)
The graph_indices of the elements in the ``graph_data`` array that are to
be split in the current operation.
rng_state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
indices_left: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
indices_right: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
"""
dim = data.shape[1]
# Select two random points, set the hyperplane between them
indices_size = np.int32(indices.shape[0])
left_index = tau_rand_int(rng_state) % indices_size
right_index = tau_rand_int(rng_state) % indices_size
right_index += left_index == right_index
right_index = right_index % indices_size
left = indices[left_index]
right = indices[right_index]
left_data = data[left]
right_data = data[right]
# Compute the normal vector to the hyperplane (the vector between
# the two points)
hyperplane_vector = np.empty(dim, dtype=np.float32)
hyperplane_norm = 0.0
for d in range(dim):
hyperplane_vector[d] = left_data[d] - right_data[d]
hyperplane_norm += hyperplane_vector[d] * hyperplane_vector[d]
hyperplane_norm = np.sqrt(hyperplane_norm)
if abs(hyperplane_norm) < EPS:
hyperplane_norm = 1.0
# Normalize in the same vector (avoiding second loop when possible)
for d in range(dim):
hyperplane_vector[d] /= hyperplane_norm
# Use temporary arrays sized for worst case, then trim
max_size = np.uint32(indices.shape[0])
temp_left = np.empty(max_size, dtype=np.int32)
temp_right = np.empty(max_size, dtype=np.int32)
n_left = 0
n_right = 0
# Single pass: classify points and directly populate result arrays
for idx in range(indices.shape[0]):
local_rng_state = rng_state + idx
point_idx = indices[idx]
test_data = data[point_idx]
margin = 0.0
# Compute margin (dot product with hyperplane normal)
for d in range(dim):
margin += hyperplane_vector[d] * test_data[d]
# Classify point and directly assign to appropriate array
if abs(margin) < EPS:
classification = tau_rand_int(local_rng_state) % 2
else:
classification = 0 if margin > 0 else 1
if classification == 0:
temp_left[n_left] = point_idx
n_left += 1
else:
temp_right[n_right] = point_idx
n_right += 1
# Handle degenerate case where all points end up on one side
if n_left == 0 or n_right == 0:
n_left = 0
n_right = 0
# Reassign randomly
for idx in range(indices.shape[0]):
point_idx = indices[idx]
classification = tau_rand_int(rng_state) % 2
if classification == 0:
temp_left[n_left] = point_idx
n_left += 1
else:
temp_right[n_right] = point_idx
n_right += 1
# Create final arrays with exact sizes (copy only what we need)
indices_left = np.empty(n_left, dtype=np.int32)
indices_right = np.empty(n_right, dtype=np.int32)
for i in range(n_left):
indices_left[i] = temp_left[i]
for j in range(n_right):
indices_right[j] = temp_right[j]
return indices_left, indices_right
@numba.njit(
numba.void(
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int32[::1],
numba.types.ListType(numba.int32[::1]),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={"left_indices": numba.int32[::1], "right_indices": numba.int32[::1]},
cache=True,
)
def make_float_tree(
data,
indices,
point_indices,
rng_state,
leaf_size=30,
max_depth=200,
):
"""
Recursively constructs a float tree for nearest neighbor descent.
Args:
data: The input data.
indices: The indices of the data points to consider.
point_indices: A list to store the indices of the points in each leaf node.
rng_state: The random number generator state.
leaf_size: The maximum number of points in a leaf node (default: 30).
max_depth: The maximum depth of the tree (default: 200).
Returns:
None
"""
if indices.shape[0] > leaf_size and max_depth > 0:
(
left_indices,
right_indices,
) = float_random_projection_split(data, indices, rng_state)
make_float_tree(
data,
left_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
make_float_tree(
data,
right_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
else:
point_indices.append(indices)
return
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={
"points": numba.int32[::1],
},
parallel=True,
cache=True,
)
def make_float_leaf_array_parallel(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(numba.int32[::1])
make_float_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = numba.int32(leaf_size)
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
n_points = numba.int32(len(points))
result[i, :n_points] = points
return result
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={
"points": numba.int32[::1],
},
parallel=False,
cache=True,
)
def make_float_leaf_array_serial(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(numba.int32[::1])
make_float_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = numba.int32(leaf_size)
for i in range(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in range(n_leaves):
points = point_indices[numba.int64(i)]
n_points = numba.int32(len(points))
result[i, :n_points] = points
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.uint64,
numba.uint64,
),
parallel=True,
cache=True,
)
def make_float_forest_no_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_float_leaf_array_serial(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.uint64,
numba.uint64,
),
parallel=True,
cache=True,
)
def make_float_forest_with_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_float_leaf_array_parallel(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
def make_float_forest(data, rng_states, leaf_size=30, max_depth=200):
if ENABLE_NESTED_PARALLELISM:
return make_float_forest_with_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
else:
return make_float_forest_no_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
@numba.njit(
numba.float32[:, :, ::1](
numba.float32[:, :, ::1],
numba.int32[::1],
numba.types.Array(numba.types.int32, 2, "C", readonly=True),
numba.float32[:],
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64,
),
parallel=True,
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"t": numba.uint16,
"r": numba.uint32,
"n": numba.uint32,
"idx": numba.uint32,
"data_p": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"max_threshold": numba.float32,
},
cache=True,
)
def generate_leaf_updates_float(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
):
block_size = leaf_block.shape[0]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
for r in range(rows_per_thread):
n = t * rows_per_thread + r
if n >= block_size:
break
for i in range(leaf_block.shape[1]):
p = leaf_block[n, i]
if p < 0:
break
data_p = data[p]
updates[t, idx, 0] = p
updates[t, idx, 1] = p
updates[t, idx, 2] = -1.0
idx += 1
for j in range(
i + 1, leaf_block.shape[1]
): # Start from i+1 to skip self-comparison
q = leaf_block[n, j]
if q < 0:
break
d = fast_cosine(data_p, data[q])
# Use max for better branch prediction than OR condition
max_threshold = max(dist_thresholds[p], dist_thresholds[q])
if d < max_threshold:
updates[t, idx, 0] = p
updates[t, idx, 1] = q
updates[t, idx, 2] = d
idx += 1
n_updates_per_thread[t] = idx
return updates
@numba.njit(
[
numba.void(
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.types.optional(
numba.types.Array(numba.types.int32, 2, "C", readonly=True)
),
numba.types.int32,
),
],
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"i": numba.uint16,
"updates": numba.float32[:, :, ::1],
"n_updates_per_thread": numba.int32[::1],
},
parallel=True,
cache=True,
)
def init_rp_tree_float(data, current_graph, leaf_array, n_threads):
n_leaves = leaf_array.shape[0]
block_size = n_threads * 64
n_blocks = n_leaves // block_size
max_leaf_size = leaf_array.shape[1]
updates_per_thread = (
int(block_size * max_leaf_size * (max_leaf_size - 1) / (2 * n_threads)) + 1
)
updates = np.zeros((n_threads, updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
n_vertices = current_graph[0].shape[0]
vertex_block_size = n_vertices // n_threads + 1
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_leaves, (i + 1) * block_size)
leaf_block = leaf_array[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
updates = generate_leaf_updates_float(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
)
for t in numba.prange(n_threads):
block_start = t * vertex_block_size
block_end = min(block_start + vertex_block_size, n_vertices)
for j in range(n_threads):
for k in range(n_updates_per_thread[j]):
p = np.int32(updates[j, k, 0])
if p == -1:
continue
q = np.int32(updates[j, k, 1])
d = np.float32(updates[j, k, 2])
if p >= block_start and p < block_end:
flagged_heap_push(
current_graph[1][p],
current_graph[0][p],
current_graph[2][p],
d,
q,
)
if q >= block_start and q < block_end:
flagged_heap_push(
current_graph[1][q],
current_graph[0][q],
current_graph[2][q],
d,
p,
)
@numba.njit(
numba.types.void(
numba.int32,
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.int64[::1],
),
fastmath=True,
parallel=True,
locals={"d": numba.float32, "idx": numba.int32, "i": numba.int32},
cache=True,
)
def init_random_float(n_neighbors, data, heap, rng_state):
for i in numba.prange(data.shape[0]):
local_rng_state = rng_state + i
if heap[0][i, 0] < 0.0:
for j in range(n_neighbors - np.sum(heap[0][i] >= 0.0)):
idx = np.abs(tau_rand_int(local_rng_state)) % data.shape[0]
if idx in heap[0][i]:
continue
d = fast_cosine(data[idx], data[i])
flagged_heap_push(heap[1][i], heap[0][i], heap[2][i], d, idx)
return
@numba.njit(
numba.types.void(
numba.float32[:, :, ::1],
numba.int32[::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"dist_thresh_p": numba.float32,
"dist_thresh_q": numba.float32,
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"max_updates": numba.int32,
"threshold_check": numba.boolean,
"max_threshold": numba.float32,
},
parallel=True,
cache=True,
fastmath=True,
boundscheck=False,
)
def generate_graph_update_array_float_basic(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
"""
Basic optimized version with aggressive optimizations but without cache-specific enhancements.
Kept for comparison and benchmarking purposes.
"""
block_size = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
max_updates = update_array.shape[1]
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size or idx >= max_updates:
break
for j in range(max_new_candidates):
if idx >= max_updates:
break
p = new_candidate_block[i, j]
if p < 0:
continue
data_p = data[p]
dist_thresh_p = dist_thresholds[p]
for k in range(j + 1, max_new_candidates):
if idx >= max_updates:
break
q = new_candidate_block[i, k]
if q < 0:
continue
# Compute distance once
d = fast_cosine(data_p, data[q])
# Use max for better branch prediction than OR condition
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
threshold_check = d <= max_threshold
if threshold_check:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
for k in range(max_old_candidates):
if idx >= max_updates:
break
q = old_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
threshold_check = d <= max_threshold
if threshold_check:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
n_updates_per_thread[t] = idx
@numba.njit(
numba.void(
numba.float32[:, :, ::1],
numba.int32[::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"dist_thresh_p": numba.float32,
"dist_thresh_q": numba.float32,
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"max_updates": numba.int32,
"threshold_check": numba.boolean,
"working_set_size": numba.int32,
"batch_start": numba.int32,
"batch_end": numba.int32,
"max_threshold": numba.float32,
},
parallel=True,
cache=True,
fastmath=True,
boundscheck=False,
)
def generate_graph_update_array_float(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
"""
Optimized version using working set approach that processes candidates in small groups
that fit well in CPU cache. This reduces cache misses by keeping frequently
accessed data vectors in cache longer, providing the best performance for typical workloads.
"""
block_size = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size // n_threads) + 1
# Working set size - process this many candidates at a time
# Tuned for typical L1/L2 cache sizes (adjust based on data dimensionality)
working_set_size = 8
for t in numba.prange(n_threads):
idx = 0
max_updates = update_array.shape[1]
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size or idx >= max_updates:
break
# Process new candidates in working set chunks
new_start = 0
while new_start < max_new_candidates and idx < max_updates:
new_end = min(new_start + working_set_size, max_new_candidates)
# Process pairs within this working set
for j in range(new_start, new_end):
if idx >= max_updates:
break
p = new_candidate_block[i, j]
if p < 0:
continue
data_p = data[p]
dist_thresh_p = dist_thresholds[p]
# Compare with other candidates in the same working set
for k in range(j + 1, new_end):
if idx >= max_updates:
break
q = new_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
threshold_check = d <= max_threshold
if threshold_check:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
# Compare with candidates in future working sets
for k in range(new_end, max_new_candidates):
if idx >= max_updates:
break
q = new_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
threshold_check = d <= max_threshold
if threshold_check:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
# Compare with old candidates in working set chunks
old_start = 0
while old_start < max_old_candidates and idx < max_updates:
old_end = min(old_start + working_set_size, max_old_candidates)
for k in range(old_start, old_end):
if idx >= max_updates:
break
q = old_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
threshold_check = d <= max_threshold
if threshold_check:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
old_start = old_end
new_start = new_end
n_updates_per_thread[t] = idx
@numba.njit(
numba.void(
numba.float32[:, :, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.float32, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.float32, 1, "C", readonly=True),
"dist_thresh_p": numba.float32,
"dist_thresh_q": numba.float32,
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"max_updates": numba.intp,
"threshold_check": numba.boolean,
"max_threshold": numba.float32,
"p_block": numba.int32,
"q_block": numba.int32,
"p_idx": numba.int32,
"q_idx": numba.int32,
},
parallel=True,
cache=True,
fastmath=True,
boundscheck=False,
)
def generate_sorted_graph_update_array_float(
update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
"""
Generate graph updates pre-sorted by target block.
Updates are bucketed by their target vertex block so that apply_sorted_graph_updates
can process each bucket with perfect data locality and no wasted iteration.
Each update (p, q, d) is placed in BOTH p's bucket and q's bucket (if different),
ensuring that each block has all updates it needs to process.
The update_array has shape (n_threads, max_updates_per_block, 3) where:
- First dimension indexes the target block
- update_array[block, idx, 0] = p (first endpoint)
- update_array[block, idx, 1] = q (second endpoint)
- update_array[block, idx, 2] = d (distance)
"""
block_size_candidates = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size_candidates // n_threads) + 1
n_vertices = data.shape[0]
vertex_block_size = n_vertices // n_threads + 1
max_updates = update_array.shape[1]
max_updates_per_src_thread = max_updates // n_threads
# Reset update counts
for b in numba.prange(n_threads):
for t in range(n_threads + 1):
n_updates_per_block[b, t] = 0
# Each thread generates updates and places them in appropriate buckets
for t in numba.prange(n_threads):
# Thread-local counters for each bucket
local_counts = np.zeros(n_threads, dtype=np.int32)
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size_candidates:
break
for j in range(max_new_candidates):
p = new_candidate_block[i, j]
if p < 0:
continue
data_p = data[p]
dist_thresh_p = dist_thresholds[p]
p_block = p // vertex_block_size
if p_block >= n_threads:
p_block = n_threads - 1
# Compare with other new candidates
for k in range(j + 1, max_new_candidates):
q = new_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Compare with old candidates
for k in range(max_old_candidates):
q = old_candidate_block[i, k]
if q < 0:
continue
d = fast_cosine(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Record total updates generated by this thread for each bucket
for b in range(n_threads):
n_updates_per_block[b, t + 1] = local_counts[b]
def nn_descent_float(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using float data.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_float(data, current_graph, leaf_array, n_threads)
init_random_float(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
n_threads = numba.get_num_threads()
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
update_array = np.empty((n_threads, max_updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
n_vertices = new_candidate_neighbors.shape[0]
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
generate_graph_update_array_float(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_graph_update_array(
current_graph, update_array, n_updates_per_thread, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
def nn_descent_float_sorted(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using float data.
This version uses pre-sorted updates bucketed by target block for potentially
better performance when n_threads is large. Each thread only processes updates
targeting its own vertex block.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_float(data, current_graph, leaf_array, n_threads)
init_random_float(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
n_threads = numba.get_num_threads()
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
# For sorted updates: shape is (n_threads, max_updates_per_block, 3)
# Each bucket (first dim) holds updates targeting that block
sorted_update_array = np.empty(
(n_threads, max_updates_per_thread, 3), dtype=np.float32
)
# Track updates per block, with per-thread breakdown: (n_threads, n_threads + 1)
# Column 0 is unused, columns 1..n_threads store count from each generating thread
n_updates_per_block = np.zeros((n_threads, n_threads + 1), dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
n_vertices = new_candidate_neighbors.shape[0]
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
# Reset update counts for this iteration
n_updates_per_block.fill(0)
generate_sorted_graph_update_array_float(
sorted_update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_sorted_graph_updates(
current_graph, sorted_update_array, n_updates_per_block, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
================================================
FILE: evoc/graph_construction.py
================================================
import numpy as np
import numba
from scipy.sparse import coo_array
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
SMOOTH_K_TOLERANCE = 1e-5
MIN_K_DIST_SCALE = 1e-3
NPY_INFINITY = np.inf
@numba.njit(
locals={
"psum": numba.types.float32,
"lo": numba.types.float32,
"mid": numba.types.float32,
"hi": numba.types.float32,
},
fastmath=True,
parallel=True,
cache=True,
)
def smooth_knn_dist(distances, k, n_iter=64, bandwidth=1.0):
target = np.log2(k) * bandwidth
rho = np.zeros(distances.shape[0], dtype=np.float32)
sigma = np.zeros(distances.shape[0], dtype=np.float32)
mean_distances = np.mean(distances)
for i in numba.prange(distances.shape[0]):
lo = 0.0
hi = NPY_INFINITY
mid = 1.0
ith_distances = distances[i]
non_zero_dists = ith_distances[ith_distances > 0.0]
if non_zero_dists.shape[0] >= 1:
rho[i] = non_zero_dists[0]
for n in range(n_iter):
psum = 0.0
for j in range(1, distances.shape[1]):
d = distances[i, j] - rho[i]
if d > 0:
psum += np.exp(-(d / mid))
else:
psum += 1.0
if np.fabs(psum - target) < SMOOTH_K_TOLERANCE:
break
if psum > target:
hi = mid
mid = (lo + hi) / 2.0
else:
lo = mid
if hi == NPY_INFINITY:
mid *= 2
else:
mid = (lo + hi) / 2.0
sigma[i] = mid
if rho[i] > 0.0:
mean_ith_distances = np.mean(ith_distances)
if sigma[i] < MIN_K_DIST_SCALE * mean_ith_distances:
sigma[i] = MIN_K_DIST_SCALE * mean_ith_distances
else:
if sigma[i] < MIN_K_DIST_SCALE * mean_distances:
sigma[i] = MIN_K_DIST_SCALE * mean_distances
return sigma, rho
@numba.njit(
locals={
"knn_dists": numba.types.float32[:, ::1],
"sigmas": numba.types.float32[::1],
"rhos": numba.types.float32[::1],
"sigma": numba.types.float32,
"rho": numba.types.float32,
"val": numba.types.float32,
},
parallel=True,
fastmath=True,
cache=True,
)
def compute_membership_strengths(
knn_indices,
knn_dists,
sigmas,
rhos,
):
n_samples = knn_indices.shape[0]
n_neighbors = knn_indices.shape[1]
rows = np.zeros(knn_indices.size, dtype=np.int32)
cols = np.zeros(knn_indices.size, dtype=np.int32)
vals = np.zeros(knn_indices.size, dtype=np.float32)
for i in range(n_samples):
rho = rhos[i]
sigma = sigmas[i]
for j in range(n_neighbors):
idx = knn_indices[i, j]
if idx == -1:
continue # We didn't get the full knn for i
elif idx == i:
val = 0.0
elif (knn_dists[i, j] - rho) <= 0.0 or sigma == 0.0:
val = 1.0
else:
val = np.exp(-((knn_dists[i, j] - rhos[i]) / (sigma)))
rows[i * n_neighbors + j] = i
cols[i * n_neighbors + j] = idx
vals[i * n_neighbors + j] = val
return rows, cols, vals
def neighbor_graph_matrix(
n_neighbors,
knn_indices,
knn_dists,
symmetrize=True,
):
"""Construct a sparse graph from k-nearest neighbor distances.
Converts k-nearest neighbor indices and distances into a weighted sparse graph
matrix using Gaussian kernel weights. Optionally symmetrizes the graph to
create an undirected graph.
Parameters
----------
n_neighbors : float
The effective number of neighbors. Used in the kernel width (sigma)
computation via the smooth_knn_dist function.
knn_indices : array-like of shape (n_samples, k)
The indices of the k-nearest neighbors for each sample.
knn_dists : array-like of shape (n_samples, k)
The distances from each sample to its k-nearest neighbors.
symmetrize : bool, default=True
If True, the graph is symmetrized using the formula:
A_sym = A + A^T - A * A^T (union of forward and reverse edges).
If False, the graph remains directed (asymmetric).
Returns
-------
graph : scipy.sparse._csr_matrix or scipy.sparse._coo_matrix
A sparse matrix representing the weighted nearest neighbor graph.
The (i, j) entry contains the Gaussian kernel weight from sample i to
sample j, or 0 if j is not in the k-nearest neighbors of i.
If symmetrize=True, the matrix is symmetric and in CSR format.
If symmetrize=False, returns a CSR matrix (asymmetric).
"""
knn_dists = knn_dists.astype(np.float32)
sigmas, rhos = smooth_knn_dist(
knn_dists,
float(n_neighbors),
)
rows, cols, vals = compute_membership_strengths(
knn_indices, knn_dists, sigmas, rhos
)
result = coo_array(
(vals, (rows, cols)),
shape=(knn_indices.shape[0], knn_indices.shape[0]),
dtype=np.float32,
)
result.eliminate_zeros()
if symmetrize:
transpose = result.transpose()
prod_matrix = result.multiply(transpose)
result = result + transpose - prod_matrix
else:
result = result.tocsr()
result.eliminate_zeros()
return result
================================================
FILE: evoc/int8_nndescent.py
================================================
import numba
import numpy as np
from .common_nndescent import (
tau_rand_int,
make_heap,
deheap_sort,
flagged_heap_push,
build_candidates,
apply_graph_update_array,
apply_sorted_graph_updates,
)
from .nested_parallelism import ENABLE_NESTED_PARALLELISM
# Used for a floating point "nearly zero" comparison
EPS = 1e-8
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
INF = np.float32(np.inf)
@numba.njit(
[
"f4(i1[::1],i1[::1])",
numba.types.float32(
numba.types.Array(numba.types.int8, 1, "C", readonly=True),
numba.types.Array(numba.types.int8, 1, "C", readonly=True),
),
],
fastmath=True,
boundscheck=False,
nogil=True,
locals={
"result": numba.types.int32,
"dim": numba.types.intp,
"i": numba.types.uint16,
},
cache=True,
)
def fast_int_inner_product_dissimilarity(x, y):
result = np.int32(0)
dim = x.shape[0]
for i in range(dim):
result += np.int32(x[i]) * np.int32(y[i])
return -np.float32(result)
@numba.njit(
numba.types.Tuple((numba.int32[::1], numba.int32[::1]))(
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int32[::1],
numba.int64[::1],
),
locals={
"n_left": numba.uint32,
"n_right": numba.uint32,
"left_data": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
"right_data": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
"test_data": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
"hyperplane_vector": numba.float32[::1],
"margin": numba.float32,
"d": numba.uint32,
"i": numba.uint32,
"left_index": numba.uint32,
"right_index": numba.uint32,
},
fastmath=True,
nogil=True,
cache=True,
)
def int8_random_projection_split(data, indices, rng_state):
"""Given a set of ``graph_indices`` for graph_data points from ``graph_data``, create
a random hyperplane to split the graph_data, returning two arrays graph_indices
that fall on either side of the hyperplane. This is the basis for a
random projection tree, which simply uses this splitting recursively.
This particular split uses cosine distance to determine the hyperplane
and which side each graph_data sample falls on.
Parameters
----------
data: array of shape (n_samples, n_features)
The original graph_data to be split
indices: array of shape (tree_node_size,)
The graph_indices of the elements in the ``graph_data`` array that are to
be split in the current operation.
rng_state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
indices_left: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
indices_right: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
"""
dim = data.shape[1]
# Select two random points, set the hyperplane between them
left_index = tau_rand_int(rng_state) % indices.shape[0]
right_index = tau_rand_int(rng_state) % indices.shape[0]
right_index += left_index == right_index
right_index = right_index % indices.shape[0]
left = indices[left_index]
right = indices[right_index]
left_data = data[left]
right_data = data[right]
left_norm = 0.0
right_norm = 0.0
for d in range(dim):
left_norm += left_data[d] * left_data[d]
right_norm += right_data[d] * right_data[d]
left_norm = np.sqrt(left_norm)
right_norm = np.sqrt(right_norm)
# Compute the normal vector to the hyperplane (the vector between
# the two points)
hyperplane_vector = np.empty(dim, dtype=np.float32)
hyperplane_norm = 0.0
for d in range(dim):
hyperplane_vector[d] = (left_data[d] / left_norm) - (right_data[d] / right_norm)
hyperplane_norm += hyperplane_vector[d] * hyperplane_vector[d]
hyperplane_norm = np.sqrt(hyperplane_norm)
# hyperplane_norm = norm(hyperplane_vector)
if abs(hyperplane_norm) < EPS:
hyperplane_norm = 1.0
for d in range(dim):
hyperplane_vector[d] /= hyperplane_norm
# For each point compute the margin (project into normal vector)
# If we are on lower side of the hyperplane put in one pile, otherwise
# put it in the other pile (if we hit hyperplane on the nose, flip a coin)
n_left = 0
n_right = 0
side = np.empty(indices.shape[0], np.bool_)
for i in range(indices.shape[0]):
margin = 0.0
local_rng_state = rng_state + np.int64(i)
test_data = data[indices[i]]
for d in range(dim):
margin += hyperplane_vector[d] * test_data[d]
if abs(margin) < EPS:
side[i] = np.bool_(tau_rand_int(local_rng_state) % 2)
if side[i] == 0:
n_left += 1
else:
n_right += 1
elif margin > 0:
side[i] = 0
n_left += 1
else:
side[i] = 1
n_right += 1
# If all points end up on one side, something went wrong numerically
# In this case, assign points randomly; they are likely very close anyway
if n_left == 0 or n_right == 0:
n_left = 0
n_right = 0
for i in range(indices.shape[0]):
side[i] = tau_rand_int(rng_state) % 2
if side[i] == 0:
n_left += 1
else:
n_right += 1
# Now that we have the counts allocate arrays
indices_left = np.empty(n_left, dtype=np.int32)
indices_right = np.empty(n_right, dtype=np.int32)
# Populate the arrays with graph_indices according to which side they fell on
n_left = 0
n_right = 0
for i in range(side.shape[0]):
if side[i] == 0:
indices_left[n_left] = indices[i]
n_left += 1
else:
indices_right[n_right] = indices[i]
n_right += 1
return indices_left, indices_right
@numba.njit(
numba.void(
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int32[::1],
numba.types.ListType(numba.int32[::1]),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
cache=True,
)
def make_int8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size=30,
max_depth=200,
):
if indices.shape[0] > leaf_size and max_depth > 0:
(
left_indices,
right_indices,
) = int8_random_projection_split(data, indices, rng_state)
make_int8_tree(
data,
left_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
make_int8_tree(
data,
right_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
else:
point_indices.append(indices)
return
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={"n_leaves": numba.int64, "i": numba.int64},
parallel=True,
cache=True,
)
def make_int8_leaf_array_parallel(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(numba.int32[::1])
make_int8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = leaf_size
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
leaf_size = numba.int32(len(points))
result[i, :leaf_size] = points
return result
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={"n_leaves": numba.int64, "i": numba.int64},
parallel=False,
cache=True,
)
def make_int8_leaf_array_serial(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(numba.int32[::1])
make_int8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = leaf_size
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
leaf_size = numba.int32(len(points))
result[i, :leaf_size] = points
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.int64,
numba.int64,
),
parallel=True,
cache=True,
)
def make_int8_forest_no_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_int8_leaf_array_serial(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.int64,
numba.int64,
),
parallel=True,
cache=True,
)
def make_int8_forest_with_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_int8_leaf_array_parallel(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
def make_int8_forest(data, rng_states, leaf_size=30, max_depth=200):
if ENABLE_NESTED_PARALLELISM:
return make_int8_forest_with_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
else:
return make_int8_forest_no_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
@numba.njit(
numba.float32[:, :, ::1](
numba.float32[:, :, ::1],
numba.int32[::1],
numba.types.Array(numba.types.int32, 2, "C", readonly=True),
numba.float32[:],
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64,
),
parallel=True,
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"t": numba.uint16,
"r": numba.uint32,
"n": numba.uint32,
"idx": numba.uint32,
"data_p": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
},
cache=True,
)
def generate_leaf_updates_int8(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
):
block_size = leaf_block.shape[0]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
for r in range(rows_per_thread):
n = t * rows_per_thread + r
if n >= block_size:
break
for i in range(leaf_block.shape[1]):
p = leaf_block[n, i]
if p < 0:
break
data_p = data[p]
for j in range(i, leaf_block.shape[1]):
q = leaf_block[n, j]
if q < 0:
break
d = fast_int_inner_product_dissimilarity(data_p, data[q])
if d < dist_thresholds[p] or d < dist_thresholds[q]:
updates[t, idx, 0] = p
updates[t, idx, 1] = q
updates[t, idx, 2] = d
idx += 1
n_updates_per_thread[t] = idx
return updates
@numba.njit(
[
numba.void(
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.types.optional(
numba.types.Array(numba.types.int32, 2, "C", readonly=True)
),
numba.types.int32,
),
],
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"i": numba.uint16,
"updates": numba.float32[:, :, ::1],
"n_updates_per_thread": numba.int32[::1],
},
parallel=True,
cache=True,
)
def init_rp_tree_int8(data, current_graph, leaf_array, n_threads):
n_leaves = leaf_array.shape[0]
block_size = n_threads * 64
n_blocks = n_leaves // block_size
max_leaf_size = leaf_array.shape[1]
updates_per_thread = (
int(block_size * max_leaf_size * (max_leaf_size - 1) / (2 * n_threads)) + 1
)
updates = np.zeros((n_threads, updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_leaves, (i + 1) * block_size)
leaf_block = leaf_array[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
updates = generate_leaf_updates_int8(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
)
n_vertices = current_graph[0].shape[0]
vertex_block_size = n_vertices // n_threads + 1
for t in numba.prange(n_threads):
block_start = t * vertex_block_size
block_end = min(block_start + vertex_block_size, n_vertices)
for j in range(n_threads):
for k in range(n_updates_per_thread[j]):
p = np.int32(updates[j, k, 0])
q = np.int32(updates[j, k, 1])
d = np.float32(updates[j, k, 2])
if p == -1 or q == -1:
continue
if p >= block_start and p < block_end:
flagged_heap_push(
current_graph[1][p],
current_graph[0][p],
current_graph[2][p],
d,
q,
)
if q >= block_start and q < block_end:
flagged_heap_push(
current_graph[1][q],
current_graph[0][q],
current_graph[2][q],
d,
p,
)
@numba.njit(
numba.types.void(
numba.int32,
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.int64[::1],
),
fastmath=True,
locals={"d": numba.float32, "idx": numba.int32, "i": numba.int32},
cache=True,
)
def init_random_int8(n_neighbors, data, heap, rng_state):
for i in range(data.shape[0]):
if heap[0][i, 0] < 0.0:
for j in range(n_neighbors - np.sum(heap[0][i] >= 0.0)):
idx = np.abs(tau_rand_int(rng_state)) % data.shape[0]
if idx in heap[0][i]:
continue
d = fast_int_inner_product_dissimilarity(data[idx], data[i])
flagged_heap_push(heap[1][i], heap[0][i], heap[2][i], d, idx)
return
@numba.njit(
numba.types.void(
numba.float32[:, :, ::1],
numba.int32[::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
},
parallel=True,
cache=True,
)
def generate_graph_update_array_int8(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
block_size = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
updates_are_full = False
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size:
break
for j in range(max_new_candidates):
p = int(new_candidate_block[i, j])
if p < 0:
continue
data_p = data[p]
for k in range(j, max_new_candidates):
q = int(new_candidate_block[i, k])
if q < 0:
continue
d = fast_int_inner_product_dissimilarity(data_p, data[q])
if d <= dist_thresholds[p] or d <= dist_thresholds[q]:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
if idx >= update_array.shape[1]:
updates_are_full = True
break
if updates_are_full:
break
for k in range(max_old_candidates):
q = int(old_candidate_block[i, k])
if q < 0:
continue
d = fast_int_inner_product_dissimilarity(data_p, data[q])
if d <= dist_thresholds[p] or d <= dist_thresholds[q]:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
if idx >= update_array.shape[1]:
updates_are_full = True
break
if updates_are_full:
break
if updates_are_full:
break
n_updates_per_thread[t] = idx
@numba.njit(
numba.void(
numba.float32[:, :, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.int8, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.int8, 1, "C", readonly=True),
"dist_thresh_p": numba.float32,
"dist_thresh_q": numba.float32,
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"max_updates": numba.intp,
"max_threshold": numba.float32,
"p_block": numba.int32,
"q_block": numba.int32,
},
parallel=True,
cache=True,
boundscheck=False,
)
def generate_sorted_graph_update_array_int8(
update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
"""
Generate graph updates pre-sorted by target block for int8 data.
"""
block_size_candidates = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size_candidates // n_threads) + 1
n_vertices = data.shape[0]
vertex_block_size = n_vertices // n_threads + 1
max_updates = update_array.shape[1]
max_updates_per_src_thread = max_updates // n_threads
# Reset update counts
for b in numba.prange(n_threads):
for t in range(n_threads + 1):
n_updates_per_block[b, t] = 0
# Each thread generates updates and places them in appropriate buckets
for t in numba.prange(n_threads):
# Thread-local counters for each bucket
local_counts = np.zeros(n_threads, dtype=np.int32)
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size_candidates:
break
for j in range(max_new_candidates):
p = new_candidate_block[i, j]
if p < 0:
continue
data_p = data[p]
dist_thresh_p = dist_thresholds[p]
p_block = p // vertex_block_size
if p_block >= n_threads:
p_block = n_threads - 1
# Compare with other new candidates
for k in range(j, max_new_candidates):
q = new_candidate_block[i, k]
if q < 0:
continue
d = fast_int_inner_product_dissimilarity(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Compare with old candidates
for k in range(max_old_candidates):
q = old_candidate_block[i, k]
if q < 0:
continue
d = fast_int_inner_product_dissimilarity(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Record total updates generated by this thread for each bucket
for b in range(n_threads):
n_updates_per_block[b, t + 1] = local_counts[b]
def nn_descent_int8(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using int8 data.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_int8(data, current_graph, leaf_array, n_threads)
init_random_int8(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
n_threads = numba.get_num_threads()
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
update_array = np.empty((n_threads, max_updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
n_vertices = new_candidate_neighbors.shape[0]
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
generate_graph_update_array_int8(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_graph_update_array(
current_graph, update_array, n_updates_per_thread, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
def nn_descent_int8_sorted(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using int8 data.
This version uses pre-sorted updates bucketed by target block for potentially
better performance when n_threads is large.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_int8(data, current_graph, leaf_array, n_threads)
init_random_int8(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
n_threads = numba.get_num_threads()
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
sorted_update_array = np.empty(
(n_threads, max_updates_per_thread, 3), dtype=np.float32
)
n_updates_per_block = np.zeros((n_threads, n_threads + 1), dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
n_vertices = new_candidate_neighbors.shape[0]
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
n_updates_per_block.fill(0)
generate_sorted_graph_update_array_int8(
sorted_update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_sorted_graph_updates(
current_graph, sorted_update_array, n_updates_per_block, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
================================================
FILE: evoc/knn_graph.py
================================================
import numpy as np
import numba
import time
from sklearn.utils import check_array, check_random_state
from warnings import warn
from .float_nndescent import (
make_float_forest,
nn_descent_float,
nn_descent_float_sorted,
)
from .uint8_nndescent import (
make_uint8_forest,
nn_descent_uint8,
nn_descent_uint8_sorted,
)
from .int8_nndescent import make_int8_forest, nn_descent_int8, nn_descent_int8_sorted
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
# Generates a timestamp for use in logging messages when verbose=True
def ts():
return time.ctime(time.time())
def make_forest(
data,
n_neighbors,
n_trees,
leaf_size,
random_state,
input_dtype,
max_depth=200,
):
"""Build a random projection forest with ``n_trees``.
Parameters
----------
data
n_neighbors
n_trees
leaf_size
rng_state
angular
Returns
-------
forest: list
A list of random projection trees.
"""
if leaf_size is None:
leaf_size = max(10, np.int32(n_neighbors))
rng_states = random_state.randint(INT32_MIN, INT32_MAX, size=(n_trees, 3)).astype(
np.int64
)
try:
if input_dtype == np.uint8:
result = make_uint8_forest(data, rng_states, leaf_size, max_depth)
elif input_dtype == np.int8:
result = make_int8_forest(data, rng_states, leaf_size, max_depth)
else:
result = make_float_forest(data, rng_states, leaf_size, max_depth)
except (RuntimeError, RecursionError, SystemError):
warn(
"Random Projection forest initialisation failed due to recursion"
"limit being reached. Something is a little strange with your "
"graph_data, and this may take longer than normal to compute."
)
return np.empty((0, 0), dtype=np.int32)
# different trees can end up with different max leaf_sizes if the tree depth is insufficient
max_leaf_size = np.max([leaf_array.shape[1] for leaf_array in result])
# pad each leaf_array from each tree out to the max_leaf_size from any tree
# so that vstack can succeed. Check np.pad docs for the specific semantics
return np.vstack(
[
np.pad(
leaf_array,
((0, 0), (0, max_leaf_size - leaf_array.shape[1])),
constant_values=-1,
)
for leaf_array in result
]
)
def nn_descent(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
input_dtype,
leaf_array=None,
verbose=False,
use_sorted_updates=True,
delta_improv=None,
):
if input_dtype == np.uint8:
if use_sorted_updates:
neighbor_graph = nn_descent_uint8_sorted(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
else:
neighbor_graph = nn_descent_uint8(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
neighbor_graph[1][:] = -np.log2(-neighbor_graph[1])
elif input_dtype == np.int8:
if use_sorted_updates:
neighbor_graph = nn_descent_int8_sorted(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
else:
neighbor_graph = nn_descent_int8(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
neighbor_graph[1][:] = 1.0 / (-neighbor_graph[1])
else:
if use_sorted_updates:
neighbor_graph = nn_descent_float_sorted(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
else:
neighbor_graph = nn_descent_float(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
delta_improv=delta_improv,
leaf_array=leaf_array,
verbose=verbose,
)
neighbor_graph[1][:] = np.maximum(-np.log2(-neighbor_graph[1]), 0.0)
return neighbor_graph
def knn_graph(
data,
n_neighbors=30,
n_trees=None,
leaf_size=None,
random_state=None,
max_candidates=None,
max_rptree_depth=200,
n_iters=None,
delta=0.001,
delta_improv=0.001,
n_jobs=None,
verbose=False,
use_sorted_updates=True,
):
"""Construct a k-nearest neighbor graph using the NN-Descent algorithm.
This function builds a k-nearest neighbor graph using random projection trees
for initialization followed by the NN-Descent algorithm for refinement. It
supports multiple data types (float32 for normalized embeddings, int8 for
quantized embeddings, uint8 for binary embeddings) with appropriate distance
metrics for each.
Parameters
----------
data : array-like of shape (n_samples, n_features)
The data for which to compute nearest neighbors. If float32, cosine distance
is used. If int8, quantized cosine distance is used. If uint8, Jaccard
distance (based on Hamming distance for binary embeddings) is used.
n_neighbors : int, default=30
The number of nearest neighbors to compute for each sample.
n_trees : int or None, default=None
The number of random projection trees to build. If None, defaults to
between 4 and 8 depending on the number of available threads.
leaf_size : int or None, default=None
The maximum number of points per leaf in the random projection trees.
If None, defaults to max(10, n_neighbors).
random_state : int, RandomState instance or None, default=None
Controls the randomness of the algorithm. Pass an int for reproducible
output across multiple function calls.
max_candidates : int or None, default=None
The maximum number of candidate neighbors to evaluate during NN-Descent.
If None, defaults to min(60, int(n_neighbors * 1.5)).
max_rptree_depth : int, default=200
Maximum depth of the random projection trees.
n_iters : int or None, default=None
Number of iterations for the NN-Descent algorithm. If None, defaults to
max(5, int(round(log2(n_samples)))).
delta : float, default=0.001
Convergence threshold for the NN-Descent algorithm.
delta_improv : float, default=0.001
Improvement threshold for early stopping in NN-Descent.
n_jobs : int or None, default=None
The number of threads to use. If -1, uses all available threads.
If None, preserves the current numba thread setting.
verbose : bool, default=False
If True, print progress messages during computation.
use_sorted_updates : bool, default=True
If True, uses a more efficient sorted update strategy in NN-Descent.
Returns
-------
neighbor_graph : tuple of (array, array)
A tuple containing:
- indices : array-like of shape (n_samples, n_neighbors)
The indices of the k-nearest neighbors for each sample.
- distances : array-like of shape (n_samples, n_neighbors)
The distances from each sample to its k-nearest neighbors.
Distances are transformed to a uniform scale based on the input dtype.
"""
if data.dtype == np.uint8:
data = check_array(data, dtype=np.uint8, order="C")
_input_dtype = np.uint8
_bit_trees = True
elif data.dtype == np.int8:
data = check_array(data, dtype=np.int8, order="C")
_input_dtype = np.int8
_bit_trees = False
else:
norms = np.einsum("ij,ij->i", data, data)
np.sqrt(norms, norms)
norms[norms == 0.0] = 1.0
if np.allclose(norms, 1.0):
# Data is already normalized, just ensure C-contiguity and float32
data = np.ascontiguousarray(data, dtype=np.float32)
else:
# Efficiently create a modifiable float32 C-contiguous copy
data = np.array(data, dtype=np.float32, order="C", copy=True)
data /= norms[:, np.newaxis]
_input_dtype = np.float32
_bit_trees = False
current_random_state = check_random_state(random_state)
rng_state = current_random_state.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
# Set threading constraints
_original_num_threads = numba.get_num_threads()
if n_jobs != -1 and n_jobs is not None:
numba.set_num_threads(n_jobs)
if n_trees is None:
n_trees = numba.get_num_threads()
n_trees = max(4, min(8, n_trees)) # Only so many trees are useful
if n_iters is None:
n_iters = max(5, int(round(np.log2(data.shape[0]))))
if verbose:
print(ts(), "Building RP forest with", str(n_trees), "trees")
leaf_array = make_forest(
data,
n_neighbors,
n_trees,
leaf_size,
current_random_state,
_input_dtype,
max_depth=max_rptree_depth,
)
if max_candidates is None:
effective_max_candidates = min(60, int(n_neighbors * 1.5))
else:
effective_max_candidates = max_candidates
if verbose:
print(ts(), "NN descent for", str(n_iters), "iterations")
neighbor_graph = nn_descent(
data,
n_neighbors,
rng_state,
effective_max_candidates,
n_iters,
delta,
_input_dtype,
leaf_array=leaf_array,
verbose=verbose,
use_sorted_updates=use_sorted_updates,
delta_improv=delta_improv,
)
if np.any(neighbor_graph[0] < 0):
warn(
"Failed to correctly find n_neighbors for some samples."
" Results may be less than ideal. Try re-running with"
" different parameters."
)
if n_jobs != -1 and n_jobs is not None:
numba.set_num_threads(_original_num_threads)
return neighbor_graph
================================================
FILE: evoc/label_propagation.py
================================================
import numpy as np
import numba
from scipy.sparse import csr_matrix
from sklearn.preprocessing import normalize
from sklearn.decomposition import PCA
from sklearn.manifold import SpectralEmbedding, MDS
from .node_embedding import node_embedding
from .common_nndescent import tau_rand, tau_rand_int
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
@numba.njit(fastmath=True, parallel=True, cache=True)
def label_prop_iteration(
indptr,
indices,
data,
labels,
rng_state,
):
n_rows = indptr.shape[0] - 1
result = labels.copy()
for i in numba.prange(n_rows):
current_l = labels[i]
if current_l >= 0:
continue
local_rng_state = rng_state + i
votes = {}
for k in range(indptr[i], indptr[i + 1]):
j = indices[k]
l = labels[j]
if l in votes:
votes[l] += data[k]
else:
votes[l] = data[k]
max_vote = 1
tie_count = 1
for l in votes:
if l == -1:
continue
elif votes[l] > max_vote:
max_vote = votes[l]
result[i] = l
tie_count = 1
elif votes[l] == max_vote:
tie_count += 1
if current_l == -1:
result[i] = l
elif tau_rand(local_rng_state) < 1.0 / tie_count:
result[i] = l
else:
continue
return result
@numba.njit(fastmath=True, parallel=True, cache=True)
def original_label_prop_iteration(
indptr,
indices,
data,
labels,
rng_state,
):
n_rows = indptr.shape[0] - 1
result = labels.copy()
for i in numba.prange(n_rows):
current_l = labels[i]
local_rng_state = rng_state + i
votes = {}
for k in range(indptr[i], indptr[i + 1]):
j = indices[k]
l = labels[j]
if l in votes:
votes[l] += data[k]
else:
votes[l] = data[k]
max_vote = 1
tie_count = 1
for l in votes:
if l == -1:
continue
elif votes[l] > max_vote:
max_vote = votes[l]
result[i] = l
tie_count = 1
elif votes[l] == max_vote:
tie_count += 1
if current_l == -1:
result[i] = l
elif tau_rand(local_rng_state) < 1.0 / tie_count:
result[i] = l
else:
continue
return result
@numba.njit(cache=True)
def label_outliers(indptr, indices, labels, rng_state):
n_rows = indptr.shape[0] - 1
max_label = labels.max()
for i in numba.prange(n_rows):
local_rng_state = rng_state + i
if labels[i] < 0:
node_queue = [i]
unlabelled = True
n_iter = 0
while unlabelled and n_iter < 64 and len(node_queue) > 0:
n_iter += 1
current_node = node_queue.pop()
for k in range(indptr[current_node], indptr[current_node + 1]):
j = indices[k]
if labels[j] >= 0:
labels[i] = labels[j]
unlabelled = False
break
else:
node_queue.append(j)
if n_iter >= 64 or unlabelled:
labels[i] = tau_rand_int(local_rng_state) % (max_label + 1)
return labels
@numba.njit(cache=True)
def remap_labels(labels):
mapping = {}
unique_labels = np.unique(labels)
if unique_labels[0] == -1:
unique_labels = unique_labels[1:]
for i, l in enumerate(unique_labels):
mapping[l] = i
next_label = i + 1
for i in range(labels.shape[0]):
if labels[i] < 0:
labels[i] = next_label
next_label += 1
else:
labels[i] = mapping[labels[i]]
return labels
def label_prop_loop(
indptr, indices, data, labels, random_state, n_iter=20, approx_n_parts=2048
):
rng_state = random_state.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
for i in range(approx_n_parts): # range(int(1.25 * approx_n_parts)):
labels[random_state.randint(labels.shape[0])] = i
for i in range(n_iter):
new_labels = label_prop_iteration(indptr, indices, data, labels, rng_state)
labels = new_labels
labels = label_outliers(indptr, indices, labels, rng_state)
return remap_labels(labels)
def original_label_prop_loop(
indptr, indices, data, labels, random_state, n_iter=20, approx_n_parts=2048
):
rng_state = random_state.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
for i in range(int(1.25 * approx_n_parts)):
labels[random_state.randint(labels.shape[0])] = i
for i in range(n_iter):
new_labels = original_label_prop_iteration(
indptr, indices, data, labels, rng_state
)
labels = new_labels
labels = label_outliers(indptr, indices, labels, rng_state)
return remap_labels(labels)
def label_propagation_init(
graph,
n_label_prop_iter=20,
n_embedding_epochs=50,
approx_n_parts=512,
n_components=2,
scaling=0.1,
random_scale=1.0,
noise_level=0.5,
random_state=None,
data=None,
recursive_init=True,
base_init="pca",
base_init_threshold=64,
upscaling="partition_expander",
):
"""Initialize a node embedding using label propagation on a sparse graph.
This function provides a high-quality initialization for node embeddings by
combining graph-based label propagation with hierarchical partitioning. For
large graphs, it recursively partitions the data and upscales the results.
For small graphs, it uses direct methods (PCA, spectral embedding, or random).
Parameters
----------
graph : scipy.sparse matrix
A sparse adjacency or weighted graph matrix representing connectivity.
n_label_prop_iter : int, default=20
Number of label propagation iterations to perform on the graph.
n_embedding_epochs : int, default=50
Number of epochs when using node embedding for upscaling.
approx_n_parts : int, default=512
Approximate number of partitions to create for recursive partitioning
of large graphs. Useful for controlling memory and computation.
n_components : int, default=2
The number of dimensions in the output embedding.
scaling : float, default=0.1
Scaling factor applied to label propagation distances.
random_scale : float, default=1.0
Scaling factor for random noise in the initialization.
noise_level : float, default=0.5
The noise level parameter passed to node embedding algorithms.
random_state : RandomState instance or None, default=None
Controls the randomness of the algorithm. If None, uses system randomness.
data : array-like of shape (n_samples, n_features) or None, default=None
The original data array. Required if base_init='pca'. Used for direct
initialization methods on small graphs.
recursive_init : bool, default=True
If True, uses recursive partitioning for large graphs. If False, applies
the base initialization method directly.
base_init : {'pca', 'random', 'spectral', 'mds'}, default='pca'
The initialization method to use for small graphs (when graph size is below
base_init_threshold). 'pca' requires the data parameter.
base_init_threshold : int, default=64
The size threshold below which the base_init method is used directly.
Graphs larger than this use recursive partitioning.
upscaling : {'partition_expander', 'node_embedding'}, default='partition_expander'
The method to use when upscaling partitions back to the full graph.
'partition_expander' uses a fast expansion method, 'node_embedding' uses
full node embedding (slower but potentially better quality).
Returns
-------
embedding : array-like of shape (n_vertices, n_components)
The initialized node embedding based on label propagation and graph structure.
"""
if random_state is None:
random_state = np.random.RandomState()
if graph.shape[0] < base_init_threshold:
if base_init == "random":
result = random_state.normal(
loc=0.0, scale=1.0, size=(graph.shape[0], n_components)
)
norms = np.linalg.norm(result, axis=1, keepdims=True)
result = result / norms
return result.astype(np.float32)
elif base_init == "pca":
result = (
PCA(n_components=n_components, random_state=random_state)
.fit_transform(data)
.astype(np.float32, order="C")
)
result -= result.mean()
result /= (result.max() - result.min()) / 2.0
return result
elif base_init == "spectral":
result = (
SpectralEmbedding(n_components=n_components, random_state=random_state)
.fit_transform(data)
.astype(np.float32, order="C")
)
result -= result.mean()
result /= (result.max() - result.min()) / 2.0
return result
elif base_init == "mds":
result = (
MDS(
n_components=n_components,
random_state=random_state,
n_init=1,
max_iter=300,
)
.fit_transform(data)
.astype(np.float32, order="C")
)
result -= result.mean()
result /= (result.max() - result.min()) / 2.0
return result
else:
raise ValueError(
"Unknown base initialization method. Should be one of ['random', 'pca', 'spectral', 'mds']"
)
labels = np.full(graph.shape[0], -1, dtype=np.int64)
partition = label_prop_loop(
graph.indptr,
graph.indices,
graph.data,
labels,
random_state,
n_label_prop_iter,
approx_n_parts,
)
base_reduction_map = csr_matrix(
(np.ones(partition.shape[0]), partition, np.arange(partition.shape[0] + 1)),
shape=(partition.shape[0], partition.max() + 1),
)
normalized_reduction_map = normalize(base_reduction_map, axis=0, norm="l2")
data_reducer = normalize(normalized_reduction_map.T, norm="l1")
if data is not None:
reduced_data = data_reducer @ data
else:
reduced_data = None
reduced_graph = normalized_reduction_map.T * graph * base_reduction_map
reduced_graph.data = np.clip(reduced_graph.data, 0.0, 1.0)
if recursive_init:
reduced_init = label_propagation_init(
reduced_graph,
n_label_prop_iter=n_label_prop_iter,
n_embedding_epochs=min(255, n_embedding_epochs),
approx_n_parts=approx_n_parts // 4,
n_components=n_components,
scaling=scaling,
random_scale=random_scale,
noise_level=noise_level,
random_state=random_state,
data=reduced_data,
recursive_init=True,
upscaling=upscaling,
base_init=base_init,
base_init_threshold=base_init_threshold,
)
else:
reduced_init = None
reduced_layout = node_embedding(
reduced_graph,
n_components,
n_embedding_epochs,
verbose=False,
noise_level=noise_level,
random_state=random_state,
initial_embedding=reduced_init,
initial_alpha=0.001 * n_embedding_epochs,
)
if upscaling == "partition_expander":
data_expander = normalize(
(graph.multiply(graph.T)) @ normalized_reduction_map, norm="l1"
)
result = (
data_expander @ reduced_layout
+ normalize(normalized_reduction_map, norm="l1") @ reduced_layout
) / 2.0
elif upscaling == "jitter_expander":
data_expander = normalize(
(graph.multiply(graph.T)) @ normalized_reduction_map, norm="l1"
)
expanded = (
data_expander @ reduced_layout
+ normalize(normalized_reduction_map, norm="l1") @ reduced_layout
) / 2.0
jittered = reduced_layout[partition]
jittered += random_state.normal(
scale=random_scale / 4.0, size=(partition.shape[0], reduced_layout.shape[1])
)
result = (expanded + jittered) / 2.0
else:
result = reduced_layout[partition]
result += random_state.normal(
scale=random_scale, size=(partition.shape[0], reduced_layout.shape[1])
)
result = (scaling * (result - result.mean(axis=0))).astype(np.float32)
return result
================================================
FILE: evoc/nested_parallelism.py
================================================
import os
import sys
import numba
def supports_safe_nesting():
# Check if user explicitly set a layer
layer = os.environ.get("NUMBA_THREADING_LAYER", "")
if layer in ("tbb", "omp"):
return True
# Check loaded libraries (if numba has already initialized)
try:
if "tbb" in numba.threading_layer():
return True
except (ValueError, numba.errors.NumbaError):
# Numba hasn't selected a layer yet, or multiple are available.
pass
# Heuristic: If on Mac and TBB is not strictly enforced/present, assume unsafe.
if sys.platform == "darwin":
# You could try importing tbb to be sure
try:
import tbb
return True
except ImportError:
return False
return True
ENABLE_NESTED_PARALLELISM = supports_safe_nesting()
================================================
FILE: evoc/node_embedding.py
================================================
import numpy as np
import numba
from tqdm import tqdm
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
def make_epochs_per_sample(weights, n_epochs):
result = np.full(weights.shape[0], n_epochs, dtype=np.float32)
n_samples = np.maximum(n_epochs * (weights / weights.max()), 1.0)
result = float(n_epochs) / np.float32(n_samples)
return result
@numba.njit(
"f4(f4[::1],f4[::1])",
fastmath=True,
cache=True,
locals={
"result": numba.types.float32,
"diff": numba.types.float32,
"dim": numba.types.intp,
"i": numba.types.intp,
},
)
def rdist(x, y):
result = 0.0
dim = x.shape[0]
for i in range(dim):
diff = x[i] - y[i]
result += diff * diff
return result
@numba.njit(inline="always")
def clip(val, lo, hi):
if val > hi:
return hi
elif val < lo:
return lo
else:
return val
@numba.njit(
"void(f4[:,::1],u4[::1],u4[::1],u4,f4[::1],u4,u1,f4,f4[::1],f4[::1],f4[::1],u1,f4)",
fastmath=True,
parallel=True,
cache=True,
locals={
"i": numba.uint32,
"j": numba.uint32,
"k": numba.uint32,
"di": numba.uint8,
"p": numba.uint8,
"n_neg_samples": numba.uint8,
"dist_squared": numba.float32,
"grad_coeff": numba.float32,
"current": numba.float32[::1],
"other": numba.float32[::1],
},
)
def node_embedding_epoch(
embedding,
head,
tail,
n_vertices,
epochs_per_sample,
rng_state,
dim,
alpha,
epochs_per_negative_sample,
epoch_of_next_negative_sample,
epoch_of_next_sample,
n,
noise_level,
):
for i in numba.prange(epochs_per_sample.shape[0]):
if epoch_of_next_sample[i] <= n:
j = head[i]
k = tail[i]
current = embedding[j]
other = embedding[k]
dist_squared = rdist(current, other)
if dist_squared > 0.0:
dist = np.sqrt(dist_squared)
grad_coeff = (-2.0 * noise_level * dist - 2.0) / (
2.0 * dist_squared - 0.5 * dist + 1.0
)
for di in range(dim):
grad_d = grad_coeff * (current[di] - other[di])
current[di] += grad_d * alpha
other[di] += -grad_d * alpha
epoch_of_next_sample[i] += epochs_per_sample[i]
n_neg_samples = int(
(n - epoch_of_next_negative_sample[i]) / epochs_per_negative_sample[i]
)
for p in range(n_neg_samples):
k = ((n + p) * i * rng_state) % n_vertices
other = embedding[k]
dist_squared = rdist(current, other)
if dist_squared > 1e-2:
grad_coeff = 4.0 / ((1.0 + 0.25 * dist_squared) * dist_squared)
for di in range(dim):
grad_d = clip(grad_coeff * (current[di] - other[di]), -4, 4)
current[di] += grad_d * alpha
epoch_of_next_negative_sample[i] += (
n_neg_samples * epochs_per_negative_sample[i]
)
@numba.njit(
"void(f4[:, ::1], u4[::1], u4[::1], u4, f4[::1], u4, u1, f4, f4[::1], f4[::1], f4[::1], u1, f4, f4, f4[:, ::1], u4[::1], u4)",
fastmath=True,
parallel=True,
cache=True,
locals={
"updates": numba.types.float32[:, ::1],
"from_node": numba.types.intp,
"to_node": numba.types.intp,
"raw_index": numba.types.intp,
"dist_squared": numba.types.float32,
"dist": numba.types.float32,
"grad_coeff": numba.types.float32,
"grad_d": numba.types.float32,
"current": numba.types.float32[::1],
"other": numba.types.float32[::1],
"block_start": numba.types.intp,
"block_end": numba.types.intp,
"node_idx": numba.types.intp,
"d": numba.types.uint8,
"n": numba.types.uint8,
"p": numba.types.uint8,
"n_neg_samples": numba.types.uint8,
},
)
def node_embedding_epoch_repr(
embedding,
csr_indptr,
csr_indices,
n_vertices,
epochs_per_sample,
rng_state,
dim,
alpha,
epochs_per_negative_sample,
epoch_of_next_negative_sample,
epoch_of_next_sample,
n,
noise_level,
gamma,
updates,
node_order,
block_size=4096,
):
for block_start in range(0, n_vertices, block_size):
block_end = min(block_start + block_size, n_vertices)
for node_idx in numba.prange(block_start, block_end):
from_node = node_order[node_idx]
current = embedding[from_node]
for raw_index in range(csr_indptr[from_node], csr_indptr[from_node + 1]):
if epoch_of_next_sample[raw_index] <= n:
to_node = csr_indices[raw_index]
other = embedding[to_node]
dist_squared = rdist(current, other)
if dist_squared > 0.0:
dist = np.sqrt(dist_squared)
grad_coeff = (-2.0 * noise_level * dist - 2.0) / (
2.0 * dist_squared - 0.5 * dist + 1.0
)
for d in range(dim):
grad_d = grad_coeff * (current[d] - other[d])
updates[from_node, d] += grad_d * alpha
epoch_of_next_sample[raw_index] += epochs_per_sample[raw_index]
n_neg_samples = int(
(n - epoch_of_next_negative_sample[raw_index])
/ epochs_per_negative_sample[raw_index]
)
for p in range(n_neg_samples):
to_node = node_order[
(raw_index * (n + p + 1) * rng_state) % n_vertices
]
other = embedding[to_node]
dist_squared = rdist(current, other)
if dist_squared > 1e-2:
grad_coeff = (
gamma
* 4.0
/ ((1.0 + 0.25 * dist_squared) * dist_squared)
)
# grad_coeff /= n_neg_samples
if grad_coeff > 0.0:
for d in range(dim):
grad_d = clip(
grad_coeff * (current[d] - other[d]), -4, 4
)
updates[from_node, d] += grad_d * alpha
epoch_of_next_negative_sample[raw_index] += (
n_neg_samples * epochs_per_negative_sample[raw_index]
)
for node_idx in numba.prange(block_start, block_end):
from_node = node_order[node_idx]
for d in range(dim):
embedding[from_node, d] += updates[from_node, d]
def node_embedding(
graph,
n_components,
n_epochs,
initial_embedding=None,
initial_alpha=0.5,
negative_sample_rate=1.0,
noise_level=0.5,
random_state=None,
reproducible_flag=True,
verbose=False,
tqdm_kwds={},
):
"""Learn a low-dimensional embedding of a graph using a UMAP-like algorithm.
This function performs stochastic gradient descent optimization to learn a
low-dimensional embedding of graph structure. It uses both positive (connected
edges) and negative (random) samples to guide the optimization.
Parameters
----------
graph : scipy.sparse matrix, typically csr_matrix or csc_matrix
A sparse adjacency matrix representing the graph. The weights in the matrix
represent connection strengths between nodes.
n_components : int
The number of dimensions in the output embedding.
n_epochs : int
The number of epochs to train the embedding.
initial_embedding : array-like of shape (n_vertices, n_components) or None, default=None
An initial embedding to use as a starting point. If None, a random
embedding is generated from a normal distribution with scale 0.25.
initial_alpha : float, default=0.5
The initial learning rate. The learning rate decays linearly over epochs.
negative_sample_rate : float, default=1.0
The rate at which negative samples are drawn relative to positive samples.
Controls the ratio of negative to positive updates per epoch.
noise_level : float, default=0.5
Controls the strength of noise in the gradient computation. Higher values
increase the tolerance for larger distances before penalizing in the
embedding space.
random_state : RandomState instance or None, default=None
Random state for reproducibility. If None, uses system randomness.
reproducible_flag : bool, default=True
If True, uses a deterministic (but slower) update strategy that processes
nodes in blocks for reproducibility. If False, uses a faster stochastic
approach.
verbose : bool, default=False
If True, display a progress bar during training.
tqdm_kwds : dict, default={}
Additional keyword arguments to pass to tqdm for progress bar customization.
Returns
-------
embedding : array-like of shape (n_vertices, n_components)
The learned low-dimensional embedding of the graph vertices.
"""
if random_state is None:
random_state = np.random.RandomState()
if initial_embedding is None:
embedding = random_state.normal(
scale=0.25, size=(graph.shape[0], n_components)
).astype(np.float32, order="C")
else:
embedding = initial_embedding
epochs_per_sample = make_epochs_per_sample(graph.data, n_epochs).astype(
np.float32, order="C"
)
epochs_per_negative_sample = epochs_per_sample / negative_sample_rate
if reproducible_flag:
epochs_per_negative_sample *= 1.5
epoch_of_next_negative_sample = epochs_per_negative_sample.copy()
epoch_of_next_sample = epochs_per_sample.copy()
if tqdm_kwds is None:
tqdm_kwds = {}
if "disable" not in tqdm_kwds:
tqdm_kwds["disable"] = not verbose
rng_val = random_state.randint(INT32_MAX, size=n_epochs)
coo_graph = graph.tocoo()
head_u4 = coo_graph.row.astype(np.uint32)
tail_u4 = coo_graph.col.astype(np.uint32)
# New
csr_indptr = graph.indptr.astype(np.uint32)
csr_indices = graph.indices.astype(np.uint32)
updates = np.zeros_like(embedding)
node_order = np.arange(graph.shape[0], dtype=np.uint32)
gamma_schedule = np.linspace(0.5, 1.5, n_epochs)
# End new
n_vertices = np.uint32(graph.shape[0])
block_size = max(1024, n_vertices // 8)
dim = np.uint8(embedding.shape[1])
alpha = np.float32(initial_alpha)
for n in tqdm(range(n_epochs), **tqdm_kwds):
if not reproducible_flag:
node_embedding_epoch(
embedding,
head_u4,
tail_u4,
n_vertices,
epochs_per_sample,
rng_val[n],
dim,
alpha,
epochs_per_negative_sample,
epoch_of_next_negative_sample,
epoch_of_next_sample,
n,
noise_level,
)
else:
node_embedding_epoch_repr(
embedding,
csr_indptr,
csr_indices,
n_vertices,
epochs_per_sample,
np.uint32(rng_val[n]),
dim,
alpha,
epochs_per_negative_sample,
epoch_of_next_negative_sample,
epoch_of_next_sample,
np.uint8(n),
np.float32(noise_level),
gamma_schedule[n],
updates,
node_order,
np.uint32(block_size),
)
updates *= (1.0 - alpha) ** 2 * 0.5
random_state.shuffle(node_order)
alpha = np.float32(initial_alpha * (1.0 - (float(n) / float(n_epochs))))
return embedding
================================================
FILE: evoc/numba_kdtree.py
================================================
import numba
import numpy as np
from collections import namedtuple
NumbaKDTree = namedtuple(
"NumbaKDTree",
["data", "idx_array", "idx_start", "idx_end", "radius", "is_leaf", "node_bounds"],
)
NodeData = namedtuple("NodeData", ["idx_start", "idx_end", "radius", "is_leaf"])
NodeDataType = numba.types.NamedTuple(
[
numba.types.intp[::1],
numba.types.intp[::1],
numba.types.float32[::1],
numba.types.bool_[::1],
],
NodeData,
)
# Create minimal sentinel instances at module level — zero cost
_sentinel_kdtree = NumbaKDTree(
data=np.empty((1, 1), dtype=np.float32),
idx_array=np.empty(1, dtype=np.intp),
idx_start=np.empty(1, dtype=np.intp),
idx_end=np.empty(1, dtype=np.intp),
radius=np.empty(1, dtype=np.float32),
is_leaf=np.empty(1, dtype=np.bool_),
node_bounds=np.empty((2, 1, 1), dtype=np.float32),
)
NumbaKDTreeType = numba.typeof(_sentinel_kdtree)
def kdtree_to_numba(sklearn_kdtree):
data, idx_array, node_data, node_bounds = sklearn_kdtree.get_arrays()
return NumbaKDTree(
data,
idx_array,
node_data.idx_start,
node_data.idx_end,
node_data.radius,
node_data.is_leaf,
node_bounds,
)
@numba.njit(
cache=True,
fastmath=True,
locals={
"n_features": numba.types.intp,
"lower_bounds": numba.types.float32[::1],
"upper_bounds": numba.types.float32[::1],
"radius": numba.types.float32,
"diff": numba.types.float32,
"data_row": numba.types.float32[::1],
},
)
def _init_node(
data,
node_bounds,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node,
idx_start,
idx_end,
):
n_features = data.shape[1]
lower_bounds = node_bounds[0, node, :]
upper_bounds = node_bounds[1, node, :]
# determine Node bounds
for j in range(n_features):
lower_bounds[j] = np.inf
upper_bounds[j] = -np.inf
for i in range(idx_start, idx_end):
data_row = data[idx_array[i]]
for j in range(n_features):
lower_bounds[j] = min(lower_bounds[j], data_row[j])
upper_bounds[j] = max(upper_bounds[j], data_row[j])
radius = 0.0
for j in range(n_features):
diff = abs(upper_bounds[j] - lower_bounds[j]) * 0.5
radius += diff * diff
idx_start_array[node] = idx_start
idx_end_array[node] = idx_end
radius_array[node] = np.sqrt(radius)
@numba.njit(
"intp(float32[:,::1], intp[::1], intp, intp)",
cache=True,
locals={
"n_features": numba.types.intp,
"result": numba.types.intp,
"max_spread": numba.types.float32,
"j": numba.types.intp,
"i": numba.types.intp,
"max_val": numba.types.float32,
"min_val": numba.types.float32,
"val": numba.types.float32,
"spread": numba.types.float32,
},
)
def _find_node_split_dim(data, idx_array, idx_start, idx_end):
n_features = data.shape[1]
result = 0
max_spread = 0
for j in range(n_features):
max_val = data[idx_array[idx_start], j]
min_val = max_val
for i in range(idx_start + 1, idx_end):
val = data[idx_array[i], j]
max_val = max(max_val, val)
min_val = min(min_val, val)
spread = max_val - min_val
if spread > max_spread:
max_spread = spread
result = j
return result
@numba.njit(
"int8(float32[:,::1], intp, intp, intp)",
fastmath=True,
cache=True,
locals={
"val1": numba.types.float32,
"val2": numba.types.float32,
},
)
def _compare_indices(data, axis, idx1, idx2):
val1 = data[idx1, axis]
val2 = data[idx2, axis]
if val1 < val2:
return -1
elif val1 > val2:
return 1
else:
# Break ties using original index values (like sklearn)
if idx1 < idx2:
return -1
elif idx1 > idx2:
return 1
else:
return 0
@numba.njit(
"void(float32[:,::1], intp[::1], intp, intp, intp)",
fastmath=True,
cache=True,
locals={
"i": numba.types.intp,
"key_idx": numba.types.intp,
"j": numba.types.intp,
},
)
def _insertion_sort_indices(data, idx_array, axis, left, right):
for i in range(left + 1, right):
key_idx = idx_array[i]
j = i - 1
while j >= left and _compare_indices(data, axis, idx_array[j], key_idx) > 0:
idx_array[j + 1] = idx_array[j]
j -= 1
idx_array[j + 1] = key_idx
@numba.njit(
"void(float32[:,::1], intp[::1], intp, intp, intp, intp)",
fastmath=True,
cache=True,
locals={
"root": numba.types.intp,
"child": numba.types.intp,
"swap": numba.types.intp,
},
)
def _sift_down_indices(data, idx_array, axis, offset, start, end):
root = start
while root * 2 + 1 < end:
child = root * 2 + 1
swap = root
if (
_compare_indices(
data, axis, idx_array[offset + swap], idx_array[offset + child]
)
< 0
):
swap = child
if (
child + 1 < end
and _compare_indices(
data, axis, idx_array[offset + swap], idx_array[offset + child + 1]
)
< 0
):
swap = child + 1
if swap == root:
return
idx_array[offset + root], idx_array[offset + swap] = (
idx_array[offset + swap],
idx_array[offset + root],
)
root = swap
@numba.njit(
"void(float32[:,::1], intp[::1], intp, intp, intp)",
cache=True,
locals={
"size": numba.types.intp,
"i": numba.types.intp,
},
)
def _heapsort_indices(data, idx_array, axis, left, right):
size = right - left
# Build heap
for i in range(size // 2 - 1, -1, -1):
_sift_down_indices(data, idx_array, axis, left, i, size)
# Extract elements
for i in range(size - 1, 0, -1):
idx_array[left], idx_array[left + i] = idx_array[left + i], idx_array[left]
_sift_down_indices(data, idx_array, axis, left, 0, i)
@numba.njit(
"intp(float32[:,::1], intp[::1], intp, intp, intp)",
fastmath=True,
cache=True,
locals={
"mid": numba.types.intp,
"idx_left": numba.types.intp,
"idx_mid": numba.types.intp,
"idx_right": numba.types.intp,
},
)
def _median_of_three_pivot(data, idx_array, axis, left, right):
mid = (left + right - 1) // 2
idx_left = idx_array[left]
idx_mid = idx_array[mid]
idx_right = idx_array[right - 1]
# Sort the three candidates
if _compare_indices(data, axis, idx_left, idx_mid) > 0:
idx_array[left], idx_array[mid] = idx_array[mid], idx_array[left]
idx_left, idx_mid = idx_mid, idx_left
if _compare_indices(data, axis, idx_mid, idx_right) > 0:
idx_array[mid], idx_array[right - 1] = idx_array[right - 1], idx_array[mid]
idx_mid, idx_right = idx_right, idx_mid
if _compare_indices(data, axis, idx_left, idx_mid) > 0:
idx_array[left], idx_array[mid] = idx_array[mid], idx_array[left]
return mid
@numba.njit(
"intp(float32[:,::1], intp[::1], intp, intp, intp, intp)",
fastmath=True,
cache=True,
locals={
"pivot_value": numba.types.float32,
"pivot_original_idx": numba.types.intp,
"i": numba.types.intp,
"j": numba.types.intp,
},
)
def _partition_indices(data, idx_array, axis, left, right, pivot_idx):
# Move pivot to end
idx_array[pivot_idx], idx_array[right - 1] = (
idx_array[right - 1],
idx_array[pivot_idx],
)
pivot_value = data[idx_array[right - 1], axis]
pivot_original_idx = idx_array[right - 1]
i = left
j = right - 2
while True:
# Find element from left that should be on right
while (
i <= j
and _compare_indices(data, axis, idx_array[i], pivot_original_idx) < 0
):
i += 1
# Find element from right that should be on left
while (
i <= j
and _compare_indices(data, axis, idx_array[j], pivot_original_idx) >= 0
):
j -= 1
if i >= j:
break
# Swap elements
idx_array[i], idx_array[j] = idx_array[j], idx_array[i]
i += 1
j -= 1
# Move pivot to final position
idx_array[i], idx_array[right - 1] = idx_array[right - 1], idx_array[i]
return i
@numba.njit(
"void(float32[:,::1], intp[::1], intp, intp, intp, intp, intp)",
cache=True,
locals={
"pivot_idx": numba.types.intp,
"pivot_pos": numba.types.intp,
},
)
def _introselect_impl(data, idx_array, axis, left, right, nth, depth_limit):
while right - left > 16:
if depth_limit == 0:
# Fall back to heapsort when recursion gets too deep
_heapsort_indices(data, idx_array, axis, left, right)
return
depth_limit -= 1
# Choose pivot using median-of-three
pivot_idx = _median_of_three_pivot(data, idx_array, axis, left, right)
# Partition around pivot
pivot_pos = _partition_indices(data, idx_array, axis, left, right, pivot_idx)
# Recurse on the appropriate side
if nth < pivot_pos:
right = pivot_pos
elif nth > pivot_pos:
left = pivot_pos + 1
else:
# Found the nth element
return
# Use insertion sort for small subarrays
_insertion_sort_indices(data, idx_array, axis, left, right)
@numba.njit(
"void(float32[:,::1], intp[::1], intp, intp, intp, intp)",
cache=True,
locals={
"size": numba.types.intp,
"max_depth": numba.types.intp,
},
)
def _introselect(data, idx_array, axis, left, right, nth):
size = right - left
# Use heapsort for small arrays or when recursion depth is too high
if size <= 16:
_insertion_sort_indices(data, idx_array, axis, left, right)
return
# Calculate maximum recursion depth (2 * log2(size))
max_depth = 2 * int(np.log2(size))
_introselect_impl(data, idx_array, axis, left, right, nth, max_depth)
@numba.njit(
"void(float32[:, ::1], intp[::1], intp[::1], intp[::1], float32[::1], bool_[::1], float32[:, :, ::1], intp, intp, intp)",
cache=True,
)
def _recursive_build_tree(
data,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node_bounds,
idx_start,
idx_end,
node,
):
n_points = idx_end - idx_start
n_mid = n_points // 2
_init_node(
data,
node_bounds,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node,
idx_start,
idx_end,
)
if 2 * node + 1 >= is_leaf_array.shape[0]:
is_leaf_array[node] = True
elif idx_end - idx_start < 2:
is_leaf_array[node] = True
else:
is_leaf_array[node] = False
axis = _find_node_split_dim(data, idx_array, idx_start, idx_end)
_introselect(data, idx_array, axis, idx_start, idx_end, idx_start + n_mid)
_recursive_build_tree(
data,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node_bounds,
idx_start,
idx_start + n_mid,
2 * node + 1,
)
_recursive_build_tree(
data,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node_bounds,
idx_start + n_mid,
idx_end,
2 * node + 2,
)
return
def build_kdtree(data, leaf_size=40):
n_samples = data.shape[0]
n_features = data.shape[1]
if leaf_size < 1:
raise ValueError("leaf_size must be greater than or equal to 1")
# determine number of levels in the tree, and from this
# the number of nodes in the tree. This results in leaf nodes
# with numbers of points between leaf_size and 2 * leaf_size
n_levels = int(np.log2(max(1, (n_samples - 1) / leaf_size)) + 1)
n_nodes = np.int32((2**n_levels) - 1)
# allocate arrays for storage
idx_array = np.arange(n_samples, dtype=np.intp)
idx_start_array = np.zeros(n_nodes, dtype=np.intp)
idx_end_array = np.zeros(n_nodes, dtype=np.intp)
radius_array = np.zeros(n_nodes, dtype=np.float32)
is_leaf_array = np.zeros(n_nodes, dtype=np.bool_)
node_bounds = np.zeros((2, n_nodes, n_features), dtype=np.float32)
_recursive_build_tree(
data,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node_bounds,
0,
n_samples,
0,
)
return NumbaKDTree(
data,
idx_array,
idx_start_array,
idx_end_array,
radius_array,
is_leaf_array,
node_bounds,
)
@numba.njit(
[
"f4(f4[::1],f4[::1])",
"f8(f8[::1],f8[::1])",
"f8(f4[::1],f8[::1])",
],
fastmath=True,
cache=True,
locals={
"dim": numba.types.intp,
"i": numba.types.uint16,
"diff": numba.types.float32,
"result": numba.types.float32,
},
)
def rdist(x, y):
result = 0.0
dim = x.shape[0]
for i in range(dim):
diff = x[i] - y[i]
result += diff * diff
return result
@numba.njit(
[
"f4(f4[::1],f4[::1],f4[::1])",
"f4(f8[::1],f8[::1],f4[::1])",
"f4(f8[::1],f8[::1],f8[::1])",
],
fastmath=True,
cache=True,
locals={
"dim": numba.types.intp,
"i": numba.types.uint16,
"d_lo": numba.types.float32,
"d_hi": numba.types.float32,
"d": numba.types.float32,
"result": numba.types.float32,
},
)
def point_to_node_lower_bound_rdist(upper, lower, pt):
result = 0.0
dim = pt.shape[0]
for i in range(dim):
d_lo = upper[i] - pt[i] if upper[i] > pt[i] else 0.0
d_hi = pt[i] - lower[i] if pt[i] > lower[i] else 0.0
d = d_lo + d_hi
result += d * d
return result
@numba.njit(
[
"i4(f4[::1],i4[::1],f4,i4)",
"i4(f8[::1],i4[::1],f8,i4)",
],
fastmath=True,
locals={
"size": numba.types.intp,
"i": numba.types.uint16,
"ic1": numba.types.uint16,
"ic2": numba.types.uint16,
"i_swap": numba.types.uint16,
},
cache=True,
)
def simple_heap_push(priorities, indices, p, n):
if p >= priorities[0]:
return 0
size = priorities.shape[0]
# insert val at position zero
priorities[0] = p
indices[0] = n
# descend the heap, swapping values until the max heap criterion is met
i = 0
while True:
ic1 = 2 * i + 1
ic2 = ic1 + 1
if ic1 >= size:
break
elif ic2 >= size:
if priorities[ic1] > p:
i_swap = ic1
else:
break
elif priorities[ic1] >= priorities[ic2]:
if p < priorities[ic1]:
i_swap = ic1
else:
break
else:
if p < priorities[ic2]:
i_swap = ic2
else:
break
priorities[i] = priorities[i_swap]
indices[i] = indices[i_swap]
i = i_swap
priorities[i] = p
indices[i] = n
return 1
@numba.njit(
fastmath=True,
cache=True,
locals={
"left_child": numba.types.intp,
"right_child": numba.types.intp,
"swap": numba.types.intp,
},
)
def siftdown(heap1, heap2, elt):
while elt * 2 + 1 < heap1.shape[0]:
left_child = elt * 2 + 1
right_child = left_child + 1
swap = elt
if heap1[swap] < heap1[left_child]:
swap = left_child
if right_child < heap1.shape[0] and heap1[swap] < heap1[right_child]:
swap = right_child
if swap == elt:
break
else:
heap1[elt], heap1[swap] = heap1[swap], heap1[elt]
heap2[elt], heap2[swap] = heap2[swap], heap2[elt]
elt = swap
@numba.njit(parallel=True, cache=True)
def deheap_sort(distances, indices):
for i in numba.prange(indices.shape[0]):
# starting from the end of the array and moving back
for j in range(indices.shape[1] - 1, 0, -1):
indices[i, 0], indices[i, j] = indices[i, j], indices[i, 0]
distances[i, 0], distances[i, j] = distances[i, j], distances[i, 0]
siftdown(distances[i, :j], indices[i, :j], 0)
return distances, indices
@numba.njit(
numba.void(
NumbaKDTreeType,
numba.types.intp,
numba.float32[::1],
numba.float32[::1],
numba.int32[::1],
numba.float32,
),
fastmath=True,
cache=True,
locals={
"node": numba.types.intp,
"left": numba.types.intp,
"right": numba.types.intp,
"d": numba.types.float32,
"idx": numba.types.uint32,
"idx_start": numba.types.intp,
"idx_end": numba.types.intp,
"is_leaf": numba.types.boolean,
"i": numba.types.intp,
"dist_lower_bound_left": numba.types.float32,
"dist_lower_bound_right": numba.types.float32,
},
)
def tree_query_recursion(
tree,
node,
point,
heap_p,
heap_i,
dist_lower_bound,
):
# Get node information
idx_start = tree.idx_start[node]
idx_end = tree.idx_end[node]
is_leaf = tree.is_leaf[node]
# ------------------------------------------------------------
# Case 1: query point is outside node radius:
# trim it from the query
if dist_lower_bound > heap_p[0]:
return
# ------------------------------------------------------------
# Case 2: this is a leaf node. Update set of nearby points
elif is_leaf:
for i in range(idx_start, idx_end):
idx = tree.idx_array[i]
d = rdist(point, tree.data[idx])
if d < heap_p[0]:
simple_heap_push(heap_p, heap_i, d, idx)
# ------------------------------------------------------------
# Case 3: Node is not a leaf. Recursively query subnodes
# starting with the closest
else:
left = 2 * node + 1
right = left + 1
dist_lower_bound_left = point_to_node_lower_bound_rdist(
tree.node_bounds[0, left], tree.node_bounds[1, left], point
)
dist_lower_bound_right = point_to_node_lower_bound_rdist(
tree.node_bounds[0, right], tree.node_bounds[1, right], point
)
# recursively query subnodes
if dist_lower_bound_left <= dist_lower_bound_right:
tree_query_recursion(
tree, left, point, heap_p, heap_i, dist_lower_bound_left
)
tree_query_recursion(
tree, right, point, heap_p, heap_i, dist_lower_bound_right
)
else:
tree_query_recursion(
tree, right, point, heap_p, heap_i, dist_lower_bound_right
)
tree_query_recursion(
tree, left, point, heap_p, heap_i, dist_lower_bound_left
)
return
@numba.njit(
numba.types.Tuple((numba.float32[:, ::1], numba.int32[:, ::1]))(
NumbaKDTreeType,
numba.float32[:, ::1],
numba.int64,
numba.types.boolean,
),
parallel=True,
fastmath=True,
cache=True,
locals={
"i": numba.types.intp,
"distance_lower_bound": numba.types.float32,
},
)
def parallel_tree_query(
tree, data, k=numba.int64(10), output_rdist=numba.types.boolean(False)
):
result = (
np.full((data.shape[0], k), np.inf, dtype=np.float32),
np.full((data.shape[0], k), -1, dtype=np.int32),
)
for i in numba.prange(data.shape[0]):
distance_lower_bound = point_to_node_lower_bound_rdist(
tree.node_bounds[0, 0], tree.node_bounds[1, 0], data[i]
)
heap_priorities, heap_indices = result[0][i], result[1][i]
tree_query_recursion(
tree,
numba.intp(0),
data[i],
heap_priorities,
heap_indices,
distance_lower_bound,
)
if output_rdist:
return deheap_sort(result[0], result[1])
else:
return deheap_sort(np.sqrt(result[0]), result[1])
================================================
FILE: evoc/tests/test_boruvka.py
================================================
"""
Comprehensive test suite for the boruvka module.
This module tests Boruvka's algorithm implementation for minimum spanning tree
construction, including component merging, tree queries, and parallel processing.
"""
import numpy as np
import pytest
import numba
from sklearn.datasets import make_blobs
from sklearn.preprocessing import StandardScaler
from evoc.boruvka import (
merge_components,
update_component_vectors,
boruvka_tree_query,
boruvka_tree_query_reproducible,
initialize_boruvka_from_knn,
parallel_boruvka,
calculate_block_size,
component_aware_query_recursion,
)
from evoc.numba_kdtree import build_kdtree
from evoc.disjoint_set import ds_rank_create, ds_find, ds_union_by_rank
class TestMergeComponents:
"""Test component merging functionality."""
def test_merge_components_basic(self):
"""Test basic component merging with simple data."""
# Create a simple disjoint set with 4 components
disjoint_set = ds_rank_create(4)
# Candidate neighbors: each point's nearest neighbor in different component
candidate_neighbors = np.array([1, 0, 3, 2], dtype=np.int32)
candidate_distances = np.array([1.0, 1.0, 2.0, 2.0], dtype=np.float32)
point_components = np.array([0, 1, 2, 3], dtype=np.int64)
result = merge_components(
disjoint_set, candidate_neighbors, candidate_distances, point_components
)
# Should have edges connecting components
assert result.shape[0] >= 1
assert result.shape[1] == 3 # from, to, distance
# Distances should be positive
assert np.all(result[:, 2] >= 0)
# Edges should connect different components
for i in range(result.shape[0]):
from_comp = ds_find(disjoint_set, int(result[i, 0]))
to_comp = ds_find(disjoint_set, int(result[i, 1]))
# After merging, they should be in same component
assert from_comp == to_comp
def test_merge_components_empty(self):
"""Test merge components with no valid edges."""
disjoint_set = ds_rank_create(2)
# Pre-merge the components
ds_union_by_rank(disjoint_set, 0, 1)
candidate_neighbors = np.array([1, 0], dtype=np.int32)
candidate_distances = np.array([1.0, 1.0], dtype=np.float32)
point_components = np.array([0, 0], dtype=np.int64) # Same component
result = merge_components(
disjoint_set, candidate_neighbors, candidate_distances, point_components
)
# Should have no edges since all points are in same component
assert result.shape[0] == 0
def test_merge_components_best_edge_selection(self):
"""Test that merge_components selects the best edge from each component."""
disjoint_set = ds_rank_create(6)
# Component 0: points 0,1 - best edge from 0 should be selected
# Component 1: points 2,3 - best edge from 2 should be selected
# Component 2: points 4,5 - best edge from 4 should be selected
ds_union_by_rank(disjoint_set, 0, 1)
ds_union_by_rank(disjoint_set, 2, 3)
ds_union_by_rank(disjoint_set, 4, 5)
# Update point components to reflect merging
point_components = np.array(
[
ds_find(disjoint_set, 0),
ds_find(disjoint_set, 1),
ds_find(disjoint_set, 2),
ds_find(disjoint_set, 3),
ds_find(disjoint_set, 4),
ds_find(disjoint_set, 5),
],
dtype=np.int64,
)
# Each point has a candidate neighbor - different distances
candidate_neighbors = np.array([2, 2, 0, 0, 0, 0], dtype=np.int32)
candidate_distances = np.array([3.0, 1.0, 2.0, 4.0, 1.5, 2.5], dtype=np.float32)
result = merge_components(
disjoint_set, candidate_neighbors, candidate_distances, point_components
)
# Should select best edges from each component
assert result.shape[0] >= 1
assert result.shape[0] <= 3 # At most 3 components to merge
class TestUpdateComponentVectors:
"""Test component vector updates."""
@pytest.fixture
def simple_tree_and_components(self):
"""Create a simple tree and component structure for testing."""
# Create simple 2D data
data = np.array(
[
[0.0, 0.0],
[0.1, 0.1], # Component 0
[1.0, 1.0],
[1.1, 1.1], # Component 1
[2.0, 2.0],
[2.1, 2.1], # Component 2
],
dtype=np.float32,
)
tree = build_kdtree(data, leaf_size=2)
# Create disjoint set and merge some components
disjoint_set = ds_rank_create(6)
ds_union_by_rank(disjoint_set, 0, 1) # Merge 0,1
ds_union_by_rank(disjoint_set, 2, 3) # Merge 2,3
ds_union_by_rank(disjoint_set, 4, 5) # Merge 4,5
point_components = np.array(
[ds_find(disjoint_set, i) for i in range(6)], dtype=np.int64
)
node_components = np.full(tree.idx_start.shape[0], -1, dtype=np.int64)
return tree, disjoint_set, point_components, node_components
def test_update_component_vectors_basic(self, simple_tree_and_components):
"""Test basic component vector update."""
tree, disjoint_set, point_components, node_components = (
simple_tree_and_components
)
update_component_vectors(tree, disjoint_set, node_components, point_components)
# Point components should be updated to component roots
unique_components = np.unique(point_components)
assert len(unique_components) == 3 # Should have 3 components
# Check that merged points have same component
assert point_components[0] == point_components[1] # Points 0,1 merged
assert point_components[2] == point_components[3] # Points 2,3 merged
assert point_components[4] == point_components[5] # Points 4,5 merged
def test_update_component_vectors_leaf_nodes(self, simple_tree_and_components):
"""Test that leaf nodes are correctly labeled when all points have same component."""
tree, disjoint_set, point_components, node_components = (
simple_tree_and_components
)
# Merge all components into one
for i in range(1, 6):
ds_union_by_rank(disjoint_set, 0, i)
# Update point components
for i in range(6):
point_components[i] = ds_find(disjoint_set, i)
update_component_vectors(tree, disjoint_set, node_components, point_components)
# All point components should be the same
assert len(np.unique(point_components)) == 1
# All leaf nodes should have the same component as their points
for i in range(tree.idx_start.shape[0]):
if tree.is_leaf[i]:
# All points in this leaf should have same component
start, end = tree.idx_start[i], tree.idx_end[i]
if end > start: # Non-empty leaf
leaf_components = [
point_components[tree.idx_array[j]] for j in range(start, end)
]
if len(set(leaf_components)) == 1: # All same component
assert node_components[i] == leaf_components[0]
class TestBoruvkaTreeQuery:
"""Test tree query functionality for Boruvka's algorithm."""
@pytest.fixture
def query_test_data(self):
"""Create test data for tree queries."""
# Create well-separated clusters
np.random.seed(42)
data = np.vstack(
[
np.random.normal([0, 0], 0.1, (10, 2)), # Cluster 0
np.random.normal([2, 0], 0.1, (10, 2)), # Cluster 1
np.random.normal([0, 2], 0.1, (10, 2)), # Cluster 2
]
).astype(np.float32)
tree = build_kdtree(data, leaf_size=5)
# Create component structure - each cluster is a component
disjoint_set = ds_rank_create(30)
point_components = np.array([i // 10 for i in range(30)], dtype=np.int64)
node_components = np.full(tree.idx_start.shape[0], -1, dtype=np.int64)
core_distances = np.zeros(30, dtype=np.float32)
return tree, node_components, point_components, core_distances
def test_boruvka_tree_query_basic(self, query_test_data):
"""Test basic tree query functionality."""
tree, node_components, point_components, core_distances = query_test_data
# Update node components
disjoint_set = ds_rank_create(30)
for i in range(30):
for j in range(i + 1, min(i + 10, 30)):
if i // 10 == j // 10: # Same cluster
ds_union_by_rank(disjoint_set, i, j)
update_component_vectors(tree, disjoint_set, node_components, point_components)
distances, indices = boruvka_tree_query(
tree, node_components, point_components, core_distances
)
# Should find nearest neighbors in different components
assert distances.shape[0] == 30
assert indices.shape[0] == 30
# All distances should be finite (found neighbors)
assert np.all(np.isfinite(distances))
# All indices should be valid
assert np.all(indices >= 0)
assert np.all(indices < 30)
# Neighbors should be in different components
for i in range(30):
if indices[i] >= 0:
assert point_components[i] != point_components[indices[i]]
def test_boruvka_tree_query_reproducible(self, query_test_data):
"""Test reproducible tree query gives consistent results."""
tree, node_components, point_components, core_distances = query_test_data
# Update node components
disjoint_set = ds_rank_create(30)
for i in range(30):
for j in range(i + 1, min(i + 10, 30)):
if i // 10 == j // 10: # Same cluster
ds_union_by_rank(disjoint_set, i, j)
update_component_vectors(tree, disjoint_set, node_components, point_components)
# Run multiple times with same block size
block_size = 8
results = []
for _ in range(3):
distances, indices = boruvka_tree_query_reproducible(
tree, node_components, point_components, core_distances, block_size
)
results.append((distances.copy(), indices.copy()))
# Results should be fairly similar (may have small variations due to ties)
for i in range(1, len(results)):
# Check that indices are valid and neighbors are in different components
distances_i, indices_i = results[i]
distances_0, indices_0 = results[0]
# All distances should be positive and finite
assert np.all(np.isfinite(distances_i))
assert np.all(distances_i > 0)
# All neighbors should be in different components
for j in range(30):
if indices_i[j] >= 0:
assert point_components[j] != point_components[indices_i[j]]
def test_boruvka_query_different_block_sizes(self, query_test_data):
"""Test that different block sizes give same results."""
tree, node_components, point_components, core_distances = query_test_data
# Update node components
disjoint_set = ds_rank_create(30)
for i in range(30):
for j in range(i + 1, min(i + 10, 30)):
if i // 10 == j // 10: # Same cluster
ds_union_by_rank(disjoint_set, i, j)
update_component_vectors(tree, disjoint_set, node_components, point_components)
# Test different block sizes
block_sizes = [4, 8, 16, 30]
results = []
for block_size in block_sizes:
distances, indices = boruvka_tree_query_reproducible(
tree, node_components, point_components, core_distances, block_size
)
results.append((distances.copy(), indices.copy()))
# All results should be valid (may have variations due to ties in nearest neighbors)
for i in range(1, len(results)):
distances_i, indices_i = results[i]
# All distances should be positive and finite
assert np.all(np.isfinite(distances_i))
assert np.all(distances_i > 0)
# All neighbors should be in different components
for j in range(30):
if indices_i[j] >= 0:
assert point_components[j] != point_components[indices_i[j]]
class TestInitializeBoruvkaFromKNN:
"""Test initialization of Boruvka from k-nearest neighbors."""
def test_initialize_boruvka_from_knn_basic(self):
"""Test basic initialization from k-NN."""
# Create simple k-NN data
knn_indices = np.array(
[
[0, 1, 2], # Point 0's neighbors: self, 1, 2
[1, 0, 2], # Point 1's neighbors: self, 0, 2
[2, 0, 1], # Point 2's neighbors: self, 0, 1
],
dtype=np.int32,
)
knn_distances = np.array(
[
[0.0, 1.0, 2.0],
[0.0, 1.0, 2.0],
[0.0, 1.5, 2.5],
],
dtype=np.float32,
)
core_distances = np.array([1.0, 1.0, 1.5], dtype=np.float32)
disjoint_set = ds_rank_create(3)
result = initialize_boruvka_from_knn(
knn_indices, knn_distances, core_distances, disjoint_set
)
# Should have edges connecting components
assert result.shape[0] >= 1
assert result.shape[1] == 3
# Edge weights should match core distances
for i in range(result.shape[0]):
from_point = int(result[i, 0])
assert result[i, 2] == core_distances[from_point]
def test_initialize_boruvka_core_distance_constraint(self):
"""Test that initialization respects core distance constraints."""
# Point 0 has high core distance, should connect to point with lower core distance
knn_indices = np.array(
[
[0, 1], # Point 0's neighbors: self, 1
[1, 0], # Point 1's neighbors: self, 0
],
dtype=np.int32,
)
knn_distances = np.array(
[
[0.0, 1.0], # Point 0 distances (squared distances)
[0.0, 1.0], # Point 1 distances (squared distances)
],
dtype=np.float32,
)
# Point 0 has higher core distance than point 1
core_distances = np.array([2.0, 1.0], dtype=np.float32)
disjoint_set = ds_rank_create(2)
result = initialize_boruvka_from_knn(
knn_indices, knn_distances, core_distances, disjoint_set
)
# Should create edge from point 0 to point 1 (lower core distance)
assert result.shape[0] == 1
assert result[0, 0] == 0 # From point 0
assert result[0, 1] == 1 # To point 1
assert (
result[0, 2] == 2.0
) # Weight is max(core_distance[0], distance) = max(2.0, 1.0) = 2.0
class TestCalculateBlockSize:
"""Test block size calculation for adaptive processing."""
def test_calculate_block_size_basic(self):
"""Test basic block size calculation."""
num_threads = 4
# Test different scenarios
scenarios = [
(10, 100, 10), # 10 components, 100 points, 10 points/component
(1, 1000, 1000), # 1 component, 1000 points, 1000 points/component
(100, 500, 5), # 100 components, 500 points, 5 points/component
(0, 100, 100), # 0 components (edge case)
]
for n_components, n_points, expected_ppc in scenarios:
block_size = calculate_block_size(n_components, n_points, num_threads)
# Block size should be reasonable
assert block_size >= num_threads
assert block_size <= n_points // 4 + 1
assert isinstance(block_size, int)
def test_calculate_block_size_extremes(self):
"""Test block size calculation for extreme cases."""
num_threads = 8
# Very large dataset
block_size = calculate_block_size(1000, 100000, num_threads)
assert block_size >= num_threads
assert block_size <= 100000 // 4 + 1
# Very small dataset
block_size = calculate_block_size(1, 10, num_threads)
assert block_size >= num_threads
# For small datasets, max() ensures block_size >= num_threads even when n_points//4+1 is smaller
expected_max = max(num_threads, 10 // 4 + 1)
assert block_size == expected_max
class TestParallelBoruvka:
"""Test the main parallel Boruvka algorithm."""
@pytest.fixture
def boruvka_test_data(self):
"""Create test data for Boruvka algorithm."""
# Create well-separated clusters that should form clear MST
np.random.seed(42)
cluster_centers = [[0, 0], [3, 0], [0, 3], [3, 3]]
data = []
for center in cluster_centers:
cluster_data = np.random.normal(center, 0.1, (5, 2))
data.append(cluster_data)
data = np.vstack(data).astype(np.float32)
tree = build_kdtree(data, leaf_size=3)
return tree, data
def test_parallel_boruvka_basic(self, boruvka_test_data):
"""Test basic Boruvka algorithm execution."""
tree, data = boruvka_test_data
num_threads = numba.get_num_threads()
# Run Boruvka with different min_samples
for min_samples in [1, 3, 5]:
edges = parallel_boruvka(
tree, num_threads, min_samples=min_samples, reproducible=False
)
# Should produce a valid MST
assert edges.shape[0] == data.shape[0] - 1 # n-1 edges for MST
assert edges.shape[1] == 3 # from, to, weight
# All edge weights should be positive
assert np.all(edges[:, 2] > 0)
# Edge endpoints should be valid indices
assert np.all(edges[:, 0] >= 0)
assert np.all(edges[:, 0] < data.shape[0])
assert np.all(edges[:, 1] >= 0)
assert np.all(edges[:, 1] < data.shape[0])
def test_parallel_boruvka_reproducible(self, boruvka_test_data):
"""Test that reproducible Boruvka gives consistent results."""
tree, data = boruvka_test_data
num_threads = numba.get_num_threads()
# Run multiple times
results = []
for _ in range(3):
edges = parallel_boruvka(
tree, num_threads, min_samples=3, reproducible=True
)
# Sort edges for comparison (edge order may vary)
sorted_edges = edges[np.lexsort((edges[:, 1], edges[:, 0]))]
results.append(sorted_edges)
# Results should be identical
for i in range(1, len(results)):
np.testing.assert_array_almost_equal(results[0], results[i], decimal=5)
def test_parallel_boruvka_vs_non_reproducible(self, boruvka_test_data):
"""Test that reproducible and non-reproducible versions give equivalent MST weights."""
tree, data = boruvka_test_data
num_threads = numba.get_num_threads()
edges_normal = parallel_boruvka(
tree, num_threads, min_samples=3, reproducible=False
)
edges_repro = parallel_boruvka(
tree, num_threads, min_samples=3, reproducible=True
)
# Both should have same number of edges
assert edges_normal.shape[0] == edges_repro.shape[0]
# Total MST weight should be the same (or very close due to floating point)
total_weight_normal = np.sum(edges_normal[:, 2])
total_weight_repro = np.sum(edges_repro[:, 2])
np.testing.assert_almost_equal(
total_weight_normal, total_weight_repro, decimal=4
)
def test_parallel_boruvka_single_point(self):
"""Test Boruvka with single point (edge case)."""
data = np.array([[0.0, 0.0]], dtype=np.float32)
tree = build_kdtree(data, leaf_size=1)
num_threads = numba.get_num_threads()
edges = parallel_boruvka(tree, num_threads, min_samples=1, reproducible=False)
# Single point should produce empty MST
assert edges.shape[0] == 0
def test_parallel_boruvka_two_points(self):
"""Test Boruvka with two points."""
data = np.array([[0.0, 0.0], [1.0, 1.0]], dtype=np.float32)
tree = build_kdtree(data, leaf_size=1)
num_threads = numba.get_num_threads()
edges = parallel_boruvka(tree, num_threads, min_samples=1, reproducible=False)
# Two points should produce single edge
assert edges.shape[0] == 1
assert edges.shape[1] == 3
# Edge should connect the two points
edge_points = set([int(edges[0, 0]), int(edges[0, 1])])
assert edge_points == {0, 1}
# Edge weight should be distance between points
expected_distance = np.sqrt(2.0) # sqrt((1-0)^2 + (1-0)^2)
np.testing.assert_almost_equal(edges[0, 2], expected_distance, decimal=5)
def test_parallel_boruvka_different_min_samples(self, boruvka_test_data):
"""Test Boruvka with different min_samples values."""
tree, data = boruvka_test_data
num_threads = numba.get_num_threads()
results = {}
for min_samples in [1, 2, 3, 5]:
edges = parallel_boruvka(
tree, num_threads, min_samples=min_samples, reproducible=False
)
results[min_samples] = edges
# All should produce valid MST
assert edges.shape[0] == data.shape[0] - 1
# Different min_samples may produce different trees, but should all be valid MSTs
# Test that all have reasonable total weights
weights = [np.sum(edges[:, 2]) for edges in results.values()]
# All weights should be positive and within reasonable range of each other
assert all(w > 0 for w in weights)
weight_ratio = max(weights) / min(weights)
assert (
weight_ratio < 10.0
) # Different min_samples can produce quite different trees
def test_parallel_boruvka_different_num_threads(self, boruvka_test_data):
"""Test Boruvka with different num_threads values."""
tree, data = boruvka_test_data
# Test different numbers of threads
thread_counts = [1, 2, 4, 8]
results = {}
for num_threads in thread_counts:
edges = parallel_boruvka(
tree, num_threads, min_samples=3, reproducible=True
)
results[num_threads] = edges
# All should produce valid MST
assert edges.shape[0] == data.shape[0] - 1
assert edges.shape[1] == 3
assert np.all(edges[:, 2] > 0)
# All results should be identical when using reproducible=True
# (since the algorithm should be deterministic regardless of thread count)
sorted_results = {}
for num_threads, edges in results.items():
sorted_edges = edges[np.lexsort((edges[:, 1], edges[:, 0]))]
sorted_results[num_threads] = sorted_edges
# Compare all results to the first one
base_result = sorted_results[thread_counts[0]]
for num_threads in thread_counts[1:]:
np.testing.assert_array_almost_equal(
base_result,
sorted_results[num_threads],
decimal=5,
err_msg=f"Results differ between 1 thread and {num_threads} threads",
)
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_empty_data_handling(self):
"""Test handling of empty data - should not raise exception as input validation happens upstream."""
# Empty data should be handled gracefully without raising exceptions
# since this is an internal function that relies on sklearn's check_array for validation
try:
data = np.empty((0, 2), dtype=np.float32)
tree = build_kdtree(data, leaf_size=1)
# If we get here, the function handled empty data gracefully
assert True
except Exception:
# If an exception is raised, that's also acceptable behavior
# since the exact handling of empty data may vary
assert True
def test_single_dimension_data(self):
"""Test with 1D data."""
data = np.array([[0.0], [1.0], [2.0]], dtype=np.float32)
tree = build_kdtree(data, leaf_size=2)
num_threads = numba.get_num_threads()
edges = parallel_boruvka(tree, num_threads, min_samples=1, reproducible=False)
# Should produce valid MST for 1D data
assert edges.shape[0] == 2 # 3 points -> 2 edges
assert np.all(edges[:, 2] > 0) # Positive weights
def test_high_dimensional_data(self):
"""Test with higher dimensional data."""
np.random.seed(42)
data = np.random.random((20, 10)).astype(np.float32) # 20 points in 10D
tree = build_kdtree(data, leaf_size=5)
num_threads = numba.get_num_threads()
edges = parallel_boruvka(tree, num_threads, min_samples=2, reproducible=False)
# Should handle high-dimensional data
assert edges.shape[0] == 19 # n-1 edges
assert np.all(edges[:, 2] > 0)
assert np.all(np.isfinite(edges[:, 2]))
if __name__ == "__main__":
pytest.main([__file__])
================================================
FILE: evoc/tests/test_cluster_trees.py
================================================
import numpy as np
import pytest
from sklearn.datasets import make_blobs
from sklearn.utils import shuffle
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import pairwise_distances
from evoc.cluster_trees import (
create_linkage_merge_data,
eliminate_branch,
linkage_merge_find,
linkage_merge_join,
mst_to_linkage_tree,
bfs_from_hierarchy,
condense_tree,
extract_leaves,
score_condensed_tree_nodes,
cluster_tree_from_condensed_tree,
extract_eom_clusters,
get_cluster_labelling_at_cut,
get_cluster_label_vector,
get_point_membership_strength_vector,
CondensedTree,
LinkageMergeData,
)
class TestLinkageMergeData:
"""Test the LinkageMergeData structure and associated functions."""
def test_create_linkage_merge_data(self):
"""Test creation of linkage merge data structure."""
base_size = 5
linkage_data = create_linkage_merge_data(base_size)
# Check structure
assert isinstance(linkage_data, LinkageMergeData)
assert len(linkage_data.parent) == 2 * base_size - 1
assert len(linkage_data.size) == 2 * base_size - 1
assert len(linkage_data.next) == 1
# Check initial values
assert np.all(linkage_data.parent == -1)
assert np.all(linkage_data.size[:base_size] == 1)
assert np.all(linkage_data.size[base_size:] == 0)
assert linkage_data.next[0] == base_size
def test_linkage_merge_find_and_join(self):
"""Test find and join operations on linkage merge data."""
base_size = 4
linkage_data = create_linkage_merge_data(base_size)
# Initially, each node should find itself
for i in range(base_size):
assert linkage_merge_find(linkage_data, i) == i
# Join nodes 0 and 1
linkage_merge_join(linkage_data, 0, 1)
# Check that parent pointers are set correctly
assert linkage_data.parent[0] == base_size # 4
assert linkage_data.parent[1] == base_size # 4
assert linkage_data.size[base_size] == 2 # Combined size
assert linkage_data.next[0] == base_size + 1 # Next available index
# Join the new cluster with node 2
new_cluster = linkage_merge_find(linkage_data, 0) # Should be 4
linkage_merge_join(linkage_data, new_cluster, 2)
# Check updated structure
assert linkage_data.size[base_size + 1] == 3 # Size should be 3
assert linkage_data.next[0] == base_size + 2 # Next available index
class TestMSTToLinkageTree:
"""Test conversion from MST to linkage tree."""
@pytest.fixture
def simple_mst(self):
"""Create a simple MST for testing."""
# Simple 4-point MST: 0-1 (dist=1.0), 1-2 (dist=2.0), 2-3 (dist=3.0)
return np.array([
[0, 1, 1.0],
[1, 2, 2.0],
[2, 3, 3.0]
], dtype=np.float64)
def test_mst_to_linkage_tree_basic(self, simple_mst):
"""Test basic MST to linkage tree conversion."""
linkage_tree = mst_to_linkage_tree(simple_mst)
# Should have same number of rows as MST
assert linkage_tree.shape[0] == simple_mst.shape[0]
assert linkage_tree.shape[1] == 4 # left, right, distance, size
# Check that distances are preserved
assert np.array_equal(linkage_tree[:, 2], simple_mst[:, 2])
# Check that cluster sizes make sense (should be increasing)
sizes = linkage_tree[:, 3]
assert sizes[0] == 2 # First merge: 2 points
assert sizes[1] == 3 # Second merge: 3 points
assert sizes[2] == 4 # Final merge: all 4 points
def test_mst_to_linkage_tree_ordering(self, simple_mst):
"""Test that linkage tree maintains proper ordering."""
linkage_tree = mst_to_linkage_tree(simple_mst)
# In each row, larger cluster index should be in column 0
for i in range(linkage_tree.shape[0]):
assert linkage_tree[i, 0] >= linkage_tree[i, 1]
def test_mst_to_linkage_tree_random(self):
"""Test with a larger random MST."""
np.random.seed(42)
n_points = 10
# Create a random MST (n_points - 1 edges)
edges = []
for i in range(n_points - 1):
edges.append([i, i + 1, np.random.random()])
mst = np.array(edges, dtype=np.float64)
mst = mst[np.argsort(mst[:, 2])] # Sort by distance
linkage_tree = mst_to_linkage_tree(mst)
assert linkage_tree.shape[0] == n_points - 1
assert linkage_tree.shape[1] == 4
assert linkage_tree[-1, 3] == n_points # Final cluster has all points
class TestBFSFromHierarchy:
"""Test breadth-first search on hierarchy."""
@pytest.fixture
def simple_hierarchy(self):
"""Create a simple hierarchy for testing.
In scipy linkage format:
- Points: 0, 1, 2, 3 (original data)
- Clusters: 4, 5, 6 (formed by merges)
- Row 0: merge to form cluster 4 (n_points + 0)
- Row 1: merge to form cluster 5 (n_points + 1)
- Row 2: merge to form cluster 6 (n_points + 2)
"""
return np.array([
[0, 1, 1.0, 2], # Row 0: merge points 0,1 -> cluster 4
[2, 3, 2.0, 2], # Row 1: merge points 2,3 -> cluster 5
[4, 5, 3.0, 4], # Row 2: merge clusters 4,5 -> cluster 6 (root)
], dtype=np.float64)
def test_bfs_leaf_node(self, simple_hierarchy):
"""Test BFS starting from a leaf node (original data point)."""
result = bfs_from_hierarchy(simple_hierarchy, 0, 4)
assert result == [0] # Leaf node should return itself
def test_bfs_internal_node(self, simple_hierarchy):
"""Test BFS starting from an internal cluster."""
# Cluster 4 (formed by merging points 0,1)
result = bfs_from_hierarchy(simple_hierarchy, 4, 4)
expected = [4, 0, 1] # Should include the cluster and its children
assert result == expected
def test_bfs_root_node(self, simple_hierarchy):
"""Test BFS starting from the root cluster."""
# Cluster 6 is the root (formed by merging clusters 4,5)
result = bfs_from_hierarchy(simple_hierarchy, 6, 4)
expected = [6, 4, 5, 0, 1, 2, 3] # Should traverse entire tree
assert set(result) == set(expected) # Order may vary in BFS
class TestCondenseTree:
"""Test tree condensation functionality."""
@pytest.fixture
def sample_hierarchy(self):
"""Create a sample hierarchy for testing."""
# Create hierarchy for 6 points
return np.array([
[0, 1, 0.1, 2], # Cluster 6: points 0,1
[2, 3, 0.2, 2], # Cluster 7: points 2,3
[6, 7, 0.3, 4], # Cluster 8: clusters 6,7
[8, 4, 0.4, 5], # Cluster 9: cluster 8 + point 4
[9, 5, 0.5, 6], # Cluster 10: cluster 9 + point 5 (root)
], dtype=np.float64)
def test_condense_tree_basic(self, sample_hierarchy):
"""Test basic tree condensation."""
min_cluster_size = 3
condensed = condense_tree(sample_hierarchy, min_cluster_size)
# Check structure
assert isinstance(condensed, CondensedTree)
assert len(condensed.parent) == len(condensed.child)
assert len(condensed.parent) == len(condensed.lambda_val)
assert len(condensed.parent) == len(condensed.child_size)
# Lambda values should be positive
assert np.all(condensed.lambda_val > 0)
# Child sizes should be reasonable
assert np.all(condensed.child_size >= 1)
def test_condense_tree_min_cluster_size_effect(self, sample_hierarchy):
"""Test that different min_cluster_size values produce different results."""
condensed_small = condense_tree(sample_hierarchy, min_cluster_size=2)
condensed_large = condense_tree(sample_hierarchy, min_cluster_size=4)
# Different min_cluster_size should affect the result structure
# (Exact comparison depends on the specific condensation logic)
assert len(condensed_small.parent) >= 0
assert len(condensed_large.parent) >= 0
def test_condense_tree_lambda_values(self, sample_hierarchy):
"""Test that lambda values are computed correctly (1/distance)."""
condensed = condense_tree(sample_hierarchy, min_cluster_size=2)
# All lambda values should be finite and positive
assert np.all(np.isfinite(condensed.lambda_val))
assert np.all(condensed.lambda_val > 0)
class TestExtractLeaves:
"""Test leaf extraction from condensed trees."""
def test_extract_leaves_simple(self):
"""Test leaf extraction from a simple condensed tree."""
# Create simple condensed tree manually
parent = np.array([5, 5, 5])
child = np.array([0, 1, 2]) # Three leaf points
lambda_val = np.array([1.0, 1.0, 1.0])
child_size = np.array([1, 1, 1])
condensed = CondensedTree(parent, child, lambda_val, child_size)
leaves = extract_leaves(condensed)
# Node 5 should be identified as a leaf cluster
assert 5 in leaves
def test_extract_leaves_hierarchical(self):
"""Test leaf extraction from a hierarchical condensed tree."""
# Create a tree where node 5 has children that are clusters (not just points)
parent = np.array([6, 6, 5, 5])
child = np.array([5, 0, 1, 2]) # Node 5 is internal (has child_size > 1)
lambda_val = np.array([1.0, 1.0, 1.0, 1.0])
child_size = np.array([3, 1, 1, 1]) # Node 5 entry has child_size=3
condensed = CondensedTree(parent, child, lambda_val, child_size)
leaves = extract_leaves(condensed)
# Based on the extract_leaves logic, clusters with child_size > 1
# in their entries are leaf clusters
if len(leaves) > 0:
for leaf in leaves:
# Find entries where this node is the child
mask = condensed.child == leaf
if np.any(mask):
# At least one entry should have child_size > 1
assert np.any(condensed.child_size[mask] > 1)
class TestClusterLabeling:
"""Test cluster labeling and membership functions."""
@pytest.fixture
def sample_condensed_tree(self):
"""Create a sample condensed tree for testing."""
parent = np.array([10, 10, 10, 11, 11])
child = np.array([0, 1, 2, 3, 4])
lambda_val = np.array([2.0, 2.0, 2.0, 1.0, 1.0])
child_size = np.array([1, 1, 1, 1, 1])
return CondensedTree(parent, child, lambda_val, child_size)
def test_get_cluster_label_vector_single_cluster(self, sample_condensed_tree):
"""Test cluster labeling with a single cluster."""
clusters = np.array([10])
labels = get_cluster_label_vector(
sample_condensed_tree, clusters, 0.0, 5
)
assert len(labels) == 5
# Points 0, 1, 2 should be in cluster 0 (they have high lambda values)
assert labels[0] == 0
assert labels[1] == 0
assert labels[2] == 0
# Points 3, 4 should be noise (-1) (they have lower lambda values)
assert labels[3] == -1
assert labels[4] == -1
def test_get_cluster_label_vector_multiple_clusters(self, sample_condensed_tree):
"""Test cluster labeling with multiple clusters."""
clusters = np.array([10, 11])
labels = get_cluster_label_vector(
sample_condensed_tree, clusters, 0.0, 5
)
assert len(labels) == 5
# Should have valid cluster assignments
unique_labels = np.unique(labels)
assert -1 in unique_labels or len(unique_labels) > 1
def test_get_point_membership_strength_vector(self, sample_condensed_tree):
"""Test membership strength calculation."""
clusters = np.array([10, 11])
labels = get_cluster_label_vector(
sample_condensed_tree, clusters, 0.0, 5
)
strengths = get_point_membership_strength_vector(
sample_condensed_tree, clusters, labels
)
assert len(strengths) == 5
assert np.all(strengths >= 0.0)
assert np.all(strengths <= 1.0)
# Points with valid cluster assignments should have positive strength
valid_points = labels >= 0
if np.any(valid_points):
assert np.all(strengths[valid_points] > 0)
class TestIntegrationWithRealData:
"""Integration tests using real clustered data."""
@pytest.fixture
def clustered_data(self):
"""Generate clustered data for integration testing."""
np.random.seed(42)
X, y = make_blobs(n_samples=50, centers=3, random_state=42)
X = StandardScaler().fit_transform(X)
return X, y
def test_full_pipeline_simple_mst(self, clustered_data):
"""Test the full pipeline with a simple MST."""
X, true_labels = clustered_data
# Create a simple MST by connecting points sequentially
n_samples = X.shape[0]
mst_edges = []
for i in range(n_samples - 1):
# Connect point i to point i+1 with random distance
mst_edges.append([i, i + 1, np.random.random()])
mst = np.array(mst_edges, dtype=np.float64)
mst = mst[np.argsort(mst[:, 2])] # Sort by distance
# Convert to linkage tree
linkage_tree = mst_to_linkage_tree(mst)
# Condense tree
condensed = condense_tree(linkage_tree, min_cluster_size=5)
# Extract clusters
leaves = extract_leaves(condensed)
# Get cluster labels
if len(leaves) > 0:
labels = get_cluster_label_vector(condensed, leaves, 0.0, n_samples)
# Basic sanity checks
assert len(labels) == n_samples
assert np.all(labels >= -1) # Valid range for labels
# Should find some clusters or noise
n_clusters = len(np.unique(labels[labels >= 0]))
assert n_clusters >= 0 # Could be all noise
def test_score_condensed_tree_nodes(self):
"""Test scoring of condensed tree nodes."""
# Create a simple condensed tree
parent = np.array([5, 5, 5])
child = np.array([0, 1, 2])
lambda_val = np.array([2.0, 1.5, 1.0])
child_size = np.array([1, 1, 1])
condensed = CondensedTree(parent, child, lambda_val, child_size)
scores = score_condensed_tree_nodes(condensed)
# Node 5 should have a positive score
assert 5 in scores
assert scores[5] > 0
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_extract_leaves_empty_tree(self):
"""Test behavior with empty condensed trees."""
empty_condensed = CondensedTree(
np.array([], dtype=np.int64),
np.array([], dtype=np.int64),
np.array([], dtype=np.float32),
np.array([], dtype=np.int64)
)
# Should handle empty input gracefully
leaves = extract_leaves(empty_condensed)
assert len(leaves) == 0 or isinstance(leaves, np.ndarray)
def test_single_point_mst(self):
"""Test with MST containing only one edge (two points)."""
mst = np.array([[0, 1, 1.0]], dtype=np.float64)
linkage_tree = mst_to_linkage_tree(mst)
assert linkage_tree.shape[0] == 1
assert linkage_tree[0, 3] == 2 # Should connect 2 points
def test_zero_distance_edges(self):
"""Test handling of zero-distance edges in MST."""
mst = np.array([
[0, 1, 0.0], # Zero distance
[1, 2, 1.0]
], dtype=np.float64)
linkage_tree = mst_to_linkage_tree(mst)
condensed = condense_tree(linkage_tree, min_cluster_size=2)
# Should handle zero distances gracefully
# (may result in infinite lambda values)
if len(condensed.lambda_val) > 0:
finite_mask = np.isfinite(condensed.lambda_val)
# At least some lambda values should be finite
assert np.any(finite_mask) or np.any(np.isinf(condensed.lambda_val))
class TestBFSEdgeCases:
"""Test edge cases for BFS functionality."""
def test_bfs_single_point_hierarchy(self):
"""Test BFS with minimal hierarchy."""
# Single merge hierarchy for 2 points
hierarchy = np.array([[0, 1, 1.0, 2]], dtype=np.float64)
result = bfs_from_hierarchy(hierarchy, 2, 2) # Cluster 2 (n_points + 0)
assert set(result) == {2, 0, 1}
def test_eliminate_branch_leaf(self):
"""Test eliminate_branch with a leaf node."""
hierarchy = np.array([[0, 1, 1.0, 2]], dtype=np.float64)
parents = np.zeros(10, dtype=np.int64)
children = np.zeros(10, dtype=np.int64)
lambdas = np.zeros(10, dtype=np.float32)
sizes = np.zeros(10, dtype=np.int64)
ignore = np.zeros(10, dtype=bool)
# Eliminate a leaf node (point 0)
new_idx = eliminate_branch(0, 5, 1.0, parents, children, lambdas,
sizes, 0, ignore, hierarchy, 2)
assert new_idx == 1 # Should increment index
assert parents[0] == 5
assert children[0] == 0
assert lambdas[0] == 1.0
# Utility function for running integration tests
def test_cluster_trees_integration():
"""High-level integration test of the entire cluster_trees module."""
np.random.seed(42)
# Generate test data
X, _ = make_blobs(n_samples=20, centers=2, random_state=42)
X = StandardScaler().fit_transform(X)
# Create a minimal MST (for testing purposes)
n_samples = X.shape[0]
mst_edges = []
for i in range(n_samples - 1):
mst_edges.append([i, i + 1, np.random.random()])
mst = np.array(mst_edges, dtype=np.float64)
mst = mst[np.argsort(mst[:, 2])]
# Test the full pipeline
linkage_tree = mst_to_linkage_tree(mst)
condensed = condense_tree(linkage_tree, min_cluster_size=3)
leaves = extract_leaves(condensed)
if len(leaves) > 0:
labels = get_cluster_label_vector(condensed, leaves, 0.0, n_samples)
strengths = get_point_membership_strength_vector(condensed, leaves, labels)
# Verify results make sense
assert len(labels) == n_samples
assert len(strengths) == n_samples
assert np.all(strengths >= 0.0) and np.all(strengths <= 1.0)
# Test passed if we reach here without errors
assert True
def test_linkage_merge_data_comprehensive():
"""Additional comprehensive test for linkage merge operations."""
base_size = 6
linkage_data = create_linkage_merge_data(base_size)
# Test multiple sequential merges
linkage_merge_join(linkage_data, 0, 1) # Creates cluster 6
linkage_merge_join(linkage_data, 2, 3) # Creates cluster 7
linkage_merge_join(linkage_data, 6, 7) # Creates cluster 8
# Verify the structure after multiple merges
assert linkage_data.size[6] == 2 # Cluster 6 has 2 points
assert linkage_data.size[7] == 2 # Cluster 7 has 2 points
assert linkage_data.size[8] == 4 # Cluster 8 has 4 points
assert linkage_data.next[0] == 9 # Next available cluster ID
# Test path compression in find
assert linkage_merge_find(linkage_data, 0) == 8 # Should find root cluster
assert linkage_merge_find(linkage_data, 2) == 8 # Should find same root
================================================
FILE: evoc/tests/test_clustering.py
================================================
"""
Comprehensive test suite for the clustering module.
This module tests the EVoC clustering algorithm implementation, including
binary search for clusters, cluster layer building, duplicate detection,
and the main EVoC class functionality.
"""
import numpy as np
import pytest
from sklearn.datasets import make_blobs, make_circles
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score, silhouette_score
from evoc.clustering import (
build_cluster_layers,
evoc_clusters,
EVoC,
)
from evoc.clustering_utilities import (
_binary_search_for_n_clusters,
find_duplicates,
_build_cluster_tree,
build_cluster_tree,
binary_search_for_n_clusters,
)
import numba
from evoc.numba_kdtree import build_kdtree
from evoc.boruvka import parallel_boruvka
from evoc.cluster_trees import mst_to_linkage_tree
@pytest.fixture
def simple_embedding_data():
"""Create simple high-dimensional embedding-like data for testing."""
# Create 512-dimensional data similar to CLIP embeddings
X, y = make_blobs(
n_samples=800, centers=4, n_features=512, cluster_std=0.8, random_state=42
)
# Normalize to unit sphere (typical for embeddings)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
return X.astype(np.float32), y
@pytest.fixture
def complex_embedding_data():
"""Create more complex high-dimensional embedding-like data for testing."""
# Create 768-dimensional data similar to sentence transformer embeddings
X, y = make_blobs(
n_samples=2000, centers=8, n_features=768, cluster_std=0.6, random_state=42
)
# Normalize to unit sphere and add some noise
X = X / np.linalg.norm(X, axis=1, keepdims=True)
X += np.random.normal(0, 0.05, X.shape)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
return X.astype(np.float32), y
@pytest.fixture
def small_embedding_data():
"""Create small high-dimensional data for quick testing."""
# Create 384-dimensional data (smaller embedding size)
X, y = make_blobs(
n_samples=300, centers=3, n_features=384, cluster_std=0.7, random_state=42
)
# Normalize to unit sphere
X = X / np.linalg.norm(X, axis=1, keepdims=True)
return X.astype(np.float32), y
@pytest.fixture
def duplicate_embedding_data():
"""Create high-dimensional embedding data with some duplicate points for testing."""
X, y = make_blobs(n_samples=400, centers=3, n_features=512, random_state=42)
# Normalize to unit sphere
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Add some duplicate points
X_with_dups = np.vstack([X, X[:20]]) # Duplicate first 20 points
y_with_dups = np.hstack([y, y[:20]])
return X_with_dups.astype(np.float32), y_with_dups
@pytest.fixture
def quantized_embedding_data():
"""Create quantized (int8) embedding data for testing."""
X, y = make_blobs(n_samples=600, centers=4, n_features=256, random_state=42)
# Normalize and quantize to int8 range
X = X / np.linalg.norm(X, axis=1, keepdims=True)
X_quantized = (X * 127).astype(np.int8)
return X_quantized, y
@pytest.fixture
def binary_embedding_data():
"""Create binary (uint8) embedding data for testing."""
X, y = make_blobs(n_samples=500, centers=3, n_features=128, random_state=42)
# Convert to binary representation
X_binary = (X > np.median(X, axis=1, keepdims=True)).astype(np.uint8)
return X_binary, y
@pytest.fixture
def small_linkage_tree():
"""Create a small linkage tree for testing."""
# Create simple high-dimensional data and build MST
X, _ = make_blobs(n_samples=100, centers=3, n_features=128, random_state=42)
# Normalize to unit sphere like embeddings
X = X / np.linalg.norm(X, axis=1, keepdims=True)
numba_tree = build_kdtree(X.astype(np.float32))
num_threads = numba.get_num_threads()
edges = parallel_boruvka(numba_tree, num_threads, min_samples=3, reproducible=False)
sorted_mst = edges[np.argsort(edges.T[2])]
return mst_to_linkage_tree(sorted_mst)
class TestBinarySearchForNClusters:
"""Test the binary search functionality for finding n clusters."""
def test_binary_search_basic(self, small_linkage_tree):
"""Test basic binary search for cluster count."""
n_samples = 100
target_clusters = 3
leaves, clusters, strengths = _binary_search_for_n_clusters(
small_linkage_tree, target_clusters, n_samples
)
# Check return types and shapes
assert isinstance(leaves, np.ndarray)
assert isinstance(clusters, np.ndarray)
assert isinstance(strengths, np.ndarray)
assert len(clusters) == n_samples
assert len(strengths) == n_samples
# Check that we have reasonable cluster count
n_clusters = len(np.unique(clusters[clusters >= 0]))
assert n_clusters > 0
assert n_clusters <= n_samples
# Check that strengths are in valid range
assert np.all(strengths >= 0)
assert np.all(strengths <= 1)
def test_binary_search_edge_cases(self, small_linkage_tree):
"""Test binary search with edge case parameters."""
n_samples = 100
# Test with very few clusters
leaves, clusters, strengths = _binary_search_for_n_clusters(
small_linkage_tree, 1, n_samples
)
assert len(clusters) == n_samples
# Test with many clusters
leaves, clusters, strengths = _binary_search_for_n_clusters(
small_linkage_tree, 50, n_samples
)
assert len(clusters) == n_samples
def test_binary_search_wrapper_function(self, simple_embedding_data):
"""Test the wrapper binary_search_for_n_clusters function."""
X, y_true = simple_embedding_data
num_threads = numba.get_num_threads()
clusters, strengths = binary_search_for_n_clusters(
X, approx_n_clusters=3, n_threads=num_threads, min_samples=5
)
# Check return types and shapes
assert isinstance(clusters, np.ndarray)
assert isinstance(strengths, np.ndarray)
assert len(clusters) == len(X)
assert len(strengths) == len(X)
# Check that we found reasonable clusters
n_clusters = len(np.unique(clusters[clusters >= 0]))
assert n_clusters > 0
assert n_clusters <= len(X)
class TestBuildClusterLayers:
"""Test the cluster layer building functionality."""
def test_build_cluster_layers_basic(self, simple_embedding_data):
"""Test basic cluster layer building."""
X, y_true = simple_embedding_data
cluster_layers, membership_strengths, persistence_scores = build_cluster_layers(
X,
min_samples=5,
base_min_cluster_size=10,
)
# Check return types
assert isinstance(cluster_layers, list)
assert isinstance(membership_strengths, list)
assert len(cluster_layers) == len(membership_strengths)
# Check that all layers have correct shape
for clusters, strengths in zip(cluster_layers, membership_strengths):
assert len(clusters) == len(X)
assert len(strengths) == len(X)
assert np.all(strengths >= 0)
assert np.all(strengths <= 1)
def test_build_cluster_layers_with_base_n_clusters(self, simple_embedding_data):
"""Test cluster layer building with specified base cluster count."""
X, y_true = simple_embedding_data
cluster_layers, membership_strengths, persistence_scores = build_cluster_layers(
X,
base_n_clusters=3,
min_samples=5,
)
assert len(cluster_layers) > 0
assert len(membership_strengths) > 0
# Check that first layer has reasonable cluster count
first_layer_clusters = cluster_layers[0]
n_clusters = len(np.unique(first_layer_clusters[first_layer_clusters >= 0]))
assert n_clusters > 0
def test_build_cluster_layers_reproducible(self, simple_embedding_data):
"""Test that cluster layer building is reproducible."""
X, y_true = simple_embedding_data
layers1, strengths1, persistence1 = build_cluster_layers(
X, base_min_cluster_size=10, reproducible_flag=True
)
layers2, strengths2, persistence2 = build_cluster_layers(
X, base_min_cluster_size=10, reproducible_flag=True
)
# Results should be identical when reproducible flag is set
assert len(layers1) == len(layers2)
for l1, l2 in zip(layers1, layers2):
np.testing.assert_array_equal(l1, l2)
class TestFindDuplicates:
"""Test the duplicate detection functionality."""
def test_find_duplicates_basic(self):
"""Test basic duplicate detection."""
# Create simple k-NN data with some duplicates
knn_inds = np.array(
[
[0, 1, 2],
[1, 0, 2],
[2, 0, 1],
[3, 0, 1], # Point 3 is close to points 0 and 1
],
dtype=np.int32,
)
knn_dists = np.array(
[
[0.0, 0.5, 1.0],
[0.5, 0.0, 1.0],
[1.0, 0.5, 0.0],
[0.8, 0.0, 0.0], # Duplicate distance (0.0) indicates duplicates
],
dtype=np.float32,
)
duplicates = find_duplicates(knn_inds, knn_dists)
# Check return type
assert isinstance(duplicates, set)
# Check that duplicates are tuples of pairs
for dup in duplicates:
assert isinstance(dup, tuple)
assert len(dup) == 2
assert dup[0] < dup[1] # Should be ordered pairs
def test_find_duplicates_no_duplicates(self):
"""Test duplicate detection when no duplicates exist."""
knn_inds = np.array([[0, 1, 2], [1, 0, 2], [2, 0, 1]], dtype=np.int32)
knn_dists = np.array(
[[0.1, 0.5, 1.0], [0.5, 0.1, 1.0], [1.0, 0.5, 0.1]], dtype=np.float32
)
duplicates = find_duplicates(knn_inds, knn_dists)
# Should find minimal or no duplicates
assert isinstance(duplicates, set)
class TestBuildClusterTree:
"""Test the cluster tree building functionality."""
def test_build_cluster_tree_basic(self):
"""Test basic cluster tree building."""
# Create simple hierarchical cluster labels
labels = [
np.array([0, 0, 1, 1, 2, 2]), # Fine-grained
np.array([0, 0, 0, 1, 1, 1]), # Coarse-grained
]
tree = build_cluster_tree(labels)
# Check return type
assert isinstance(tree, dict)
# Check that keys are tuples (layer, cluster)
for key in tree.keys():
assert isinstance(key, tuple)
assert len(key) == 2
assert isinstance(key[0], (int, np.integer))
assert isinstance(key[1], (int, np.integer))
# Check that values are lists of child clusters
for value in tree.values():
assert isinstance(value, list)
for child in value:
assert isinstance(child, tuple)
assert len(child) == 2
def test_build_cluster_tree_empty(self):
"""Test cluster tree building with empty input."""
labels = []
# Empty input should be handled gracefully
# Note: This may raise an error due to numba limitations with empty lists
with pytest.raises((ValueError, Exception)):
tree = build_cluster_tree(labels)
def test_build_cluster_tree_single_layer(self):
"""Test cluster tree building with single layer."""
labels = [np.array([0, 1, 0, 1, 2])]
tree = build_cluster_tree(labels)
assert isinstance(tree, dict)
class TestEvocClusters:
"""Test the main evoc_clusters function."""
def test_evoc_clusters_basic(self, simple_embedding_data):
"""Test basic EVoC clustering."""
X, y_true = simple_embedding_data
cluster_layers, membership_strengths, persistence_scores, _, _ = evoc_clusters(
X,
noise_level=0.5,
base_min_cluster_size=5,
base_n_clusters=2,
n_neighbors=10,
min_samples=3,
n_epochs=20,
random_state=np.random.RandomState(42),
)
# Check return types
assert isinstance(cluster_layers, list)
assert isinstance(membership_strengths, list)
assert len(cluster_layers) == len(membership_strengths)
assert len(cluster_layers) > 0
# Check shapes
for clusters, strengths in zip(cluster_layers, membership_strengths):
assert len(clusters) == len(X)
assert len(strengths) == len(X)
def test_evoc_clusters_with_approx_n_clusters(self, simple_embedding_data):
"""Test EVoC clustering with specified cluster count."""
X, y_true = simple_embedding_data
cluster_layers, membership_strengths, persistence_scores, _, _ = evoc_clusters(
X,
approx_n_clusters=3,
n_neighbors=10,
min_samples=3,
n_epochs=20,
random_state=np.random.RandomState(42),
)
# Should return exactly one layer
assert len(cluster_layers) == 1
assert len(membership_strengths) == 1
# Check that we found reasonable clusters
clusters = cluster_layers[0]
n_clusters = len(np.unique(clusters[clusters >= 0]))
assert n_clusters > 0
def test_evoc_clusters_with_duplicates(self, duplicate_embedding_data):
"""Test EVoC clustering with duplicate detection."""
X, y_true = duplicate_embedding_data
cluster_layers, membership_strengths, persistence_scores, _, _, duplicates = (
evoc_clusters(
X,
return_duplicates=True,
n_neighbors=10,
min_samples=3,
n_epochs=20,
random_state=np.random.RandomState(42),
)
)
# Check that duplicates are returned
assert isinstance(duplicates, set)
# Check other return values
assert isinstance(cluster_layers, list)
assert isinstance(membership_strengths, list)
def test_evoc_clusters_different_data_types(
self, quantized_embedding_data, binary_embedding_data
):
"""Test EVoC clustering with different embedding data types."""
# Test with float32 data (standard embeddings)
X_float = np.random.rand(100, 256).astype(np.float32)
# Normalize like real embeddings
X_float = X_float / np.linalg.norm(X_float, axis=1, keepdims=True)
clusters, strengths, persistence_scores, _, _ = evoc_clusters(
X_float,
approx_n_clusters=4,
n_epochs=10,
random_state=np.random.RandomState(42),
)
assert len(clusters) == 1
assert len(clusters[0]) == 100
# Test with int8 data (quantized embeddings)
X_int8, _ = quantized_embedding_data
clusters, strengths, persistence_scores, _, _ = evoc_clusters(
X_int8,
approx_n_clusters=3,
n_epochs=10,
random_state=np.random.RandomState(42),
)
assert len(clusters) == 1
assert len(clusters[0]) == len(X_int8)
# Test with uint8 data (binary embeddings)
X_uint8, _ = binary_embedding_data
clusters, strengths, persistence_scores, _, _ = evoc_clusters(
X_uint8,
approx_n_clusters=3,
n_epochs=10,
random_state=np.random.RandomState(42),
)
assert len(clusters) == 1
assert len(clusters[0]) == len(X_uint8)
class TestEVoCClass:
"""Test the EVoC class implementation."""
def test_evoc_init(self):
"""Test EVoC class initialization."""
clusterer = EVoC(
noise_level=0.3,
base_min_cluster_size=10,
n_neighbors=20,
n_epochs=30,
random_state=42,
)
# Check that parameters are set correctly
assert clusterer.noise_level == 0.3
assert clusterer.base_min_cluster_size == 10
assert clusterer.n_neighbors == 20
assert clusterer.n_epochs == 30
assert clusterer.random_state == 42
def test_evoc_fit_predict(self, simple_embedding_data):
"""Test EVoC fit_predict method."""
X, y_true = simple_embedding_data
clusterer = EVoC(
base_min_cluster_size=5, n_neighbors=10, n_epochs=20, random_state=42
)
labels = clusterer.fit_predict(X)
# Check return type and shape
assert isinstance(labels, np.ndarray)
assert len(labels) == len(X)
# Check that clusterer has fitted attributes
assert hasattr(clusterer, "labels_")
assert hasattr(clusterer, "membership_strengths_")
assert hasattr(clusterer, "cluster_layers_")
assert hasattr(clusterer, "membership_strength_layers_")
assert hasattr(clusterer, "duplicates_")
# Check that labels are consistent
np.testing.assert_array_equal(labels, clusterer.labels_)
def test_evoc_fit(self, simple_embedding_data):
"""Test EVoC fit method."""
X, y_true = simple_embedding_data
clusterer = EVoC(
base_min_cluster_size=5, n_neighbors=10, n_epochs=20, random_state=42
)
result = clusterer.fit(X)
# Check that fit returns self
assert result is clusterer
# Check that clusterer has fitted attributes
assert hasattr(clusterer, "labels_")
assert hasattr(clusterer, "membership_strengths_")
def test_evoc_with_approx_n_clusters(self, simple_embedding_data):
"""Test EVoC with specified cluster count."""
X, y_true = simple_embedding_data
clusterer = EVoC(
approx_n_clusters=3, n_neighbors=10, n_epochs=20, random_state=42
)
labels = clusterer.fit_predict(X)
# Check that we have reasonable cluster count
n_clusters = len(np.unique(labels[labels >= 0]))
assert n_clusters > 0
assert n_clusters <= len(X)
def test_evoc_cluster_tree_property(self, simple_embedding_data):
"""Test EVoC cluster_tree_ property."""
X, y_true = simple_embedding_data
clusterer = EVoC(
base_min_cluster_size=5, n_neighbors=10, n_epochs=20, random_state=42
)
clusterer.fit(X)
# Test cluster tree property
tree = clusterer.cluster_tree_
assert isinstance(tree, dict)
def test_evoc_cluster_tree_not_fitted(self):
"""Test that cluster_tree_ raises error when not fitted."""
clusterer = EVoC()
with pytest.raises(Exception): # Should raise NotFittedError or similar
_ = clusterer.cluster_tree_
def test_evoc_reproducibility(self, simple_embedding_data):
"""Test that EVoC produces reproducible results."""
X, y_true = simple_embedding_data
clusterer1 = EVoC(
base_min_cluster_size=5, n_neighbors=10, n_epochs=20, random_state=42
)
clusterer2 = EVoC(
base_min_cluster_size=5, n_neighbors=10, n_epochs=20, random_state=42
)
labels1 = clusterer1.fit_predict(X)
labels2 = clusterer2.fit_predict(X)
# Results should be identical with same random state
np.testing.assert_array_equal(labels1, labels2)
class TestClusteringQuality:
"""Test clustering quality metrics and edge cases."""
def test_clustering_quality_on_embeddings(self, simple_embedding_data):
"""Test that clustering works well on high-dimensional embedding data."""
X, y_true = simple_embedding_data
clusterer = EVoC(
base_n_clusters=4, # Match true number of clusters
n_neighbors=15,
n_epochs=30,
random_state=42,
)
try:
labels = clusterer.fit_predict(X)
# Calculate clustering quality metrics
# Remove noise points for ARI calculation
mask = labels >= 0
if np.sum(mask) > 0:
ari = adjusted_rand_score(y_true[mask], labels[mask])
# Should achieve reasonable clustering quality on embeddings
assert ari > 0.2 # Relaxed threshold for high-dimensional data
# Check silhouette score (only if we have multiple clusters)
n_clusters = len(np.unique(labels[labels >= 0]))
if n_clusters > 1:
sil_score = silhouette_score(X[mask], labels[mask])
assert sil_score > 0.05 # Very relaxed for high-dimensional data
except ValueError as e:
# Handle case where clustering fails to find layers
if "empty sequence" in str(e):
pytest.skip("Clustering failed to find cluster layers for this data")
def test_clustering_quality_on_blobs(self):
"""Test that clustering works on traditional blob data for compatibility."""
# Create traditional 2D blob data for compatibility testing
X, y_true = make_blobs(
n_samples=100, centers=3, n_features=2, cluster_std=1.0, random_state=42
)
X = StandardScaler().fit_transform(X).astype(np.float32)
clusterer = EVoC(
base_n_clusters=3, # Match true number of clusters
n_neighbors=15,
n_epochs=30,
random_state=42,
)
try:
labels = clusterer.fit_predict(X)
# Calculate clustering quality metrics
# Remove noise points for ARI calculation
mask = labels >= 0
if np.sum(mask) > 0:
ari = adjusted_rand_score(y_true[mask], labels[mask])
# Should achieve good clustering quality on simple blobs
assert ari > 0.3
# Check silhouette score (only if we have multiple clusters)
n_clusters = len(np.unique(labels[labels >= 0]))
if n_clusters > 1:
sil_score = silhouette_score(X[mask], labels[mask])
assert sil_score > 0.1
except ValueError as e:
# Handle case where clustering fails to find layers
if "empty sequence" in str(e):
pytest.skip("Clustering failed to find cluster layers for this data")
def test_clustering_on_small_dataset(self):
"""Test clustering on very small dataset."""
# Use small but realistic embedding-like data
X = np.random.rand(50, 256)
# Normalize like embeddings
X = X / np.linalg.norm(X, axis=1, keepdims=True)
X = X.astype(np.float32)
clusterer = EVoC(
base_min_cluster_size=2, n_neighbors=5, n_epochs=10, random_state=42
)
try:
labels = clusterer.fit_predict(X)
# Should not crash and should return valid labels
assert len(labels) == len(X)
assert np.all((labels >= -1) & (labels < len(X)))
except ValueError as e:
# Handle case where clustering fails due to small dataset
if "empty sequence" in str(e):
pytest.skip(
"Clustering failed on very small dataset - expected behavior"
)
def test_clustering_on_high_dimensional_data(self):
"""Test clustering on very high-dimensional embedding data."""
# Test with 1024-dimensional data similar to large transformer models
X = np.random.rand(500, 1024)
# Normalize to unit sphere like real embeddings
X = X / np.linalg.norm(X, axis=1, keepdims=True)
X = X.astype(np.float32)
clusterer = EVoC(
base_min_cluster_size=8, n_neighbors=12, n_epochs=20, random_state=42
)
labels = clusterer.fit_predict(X)
# Should handle very high dimensions gracefully
assert len(labels) == len(X)
assert np.all((labels >= -1) & (labels < len(X)))
def test_edge_case_single_cluster(self):
"""Test edge case where all data forms single cluster."""
# Create very tight cluster
X = np.random.normal(0, 0.01, (50, 5))
clusterer = EVoC(
base_min_cluster_size=10, n_neighbors=15, n_epochs=20, random_state=42
)
try:
labels = clusterer.fit_predict(X)
# Should handle single cluster case
assert len(labels) == len(X)
except ValueError as e:
# Handle case where clustering fails due to single tight cluster
if "empty sequence" in str(e):
pytest.skip(
"Clustering failed on single tight cluster - expected behavior"
)
def test_parameter_validation(self):
"""Test that invalid parameters are handled appropriately."""
# These should not crash during initialization
clusterer = EVoC(
noise_level=-0.1, # Invalid but should be clamped/handled
base_min_cluster_size=1, # Very small
n_neighbors=1, # Very small
n_epochs=1, # Very small
)
# Should initialize without error
assert isinstance(clusterer, EVoC)
def test_clustering_on_clip_like_embeddings(self):
"""Test clustering on CLIP-like 512-dimensional embeddings."""
# Simulate CLIP embeddings with multiple semantic clusters
np.random.seed(42)
n_samples_per_cluster = 80
n_clusters = 5
cluster_centers = np.random.randn(n_clusters, 512)
cluster_centers = cluster_centers / np.linalg.norm(
cluster_centers, axis=1, keepdims=True
)
X = []
y_true = []
for i, center in enumerate(cluster_centers):
# Generate points around each center
cluster_points = center + np.random.normal(
0, 0.1, (n_samples_per_cluster, 512)
)
# Normalize to unit sphere
cluster_points = cluster_points / np.linalg.norm(
cluster_points, axis=1, keepdims=True
)
X.append(cluster_points)
y_true.extend([i] * n_samples_per_cluster)
X = np.vstack(X).astype(np.float32)
y_true = np.array(y_true)
clusterer = EVoC(
base_n_clusters=n_clusters, n_neighbors=15, n_epochs=25, random_state=42
)
labels = clusterer.fit_predict(X)
# Should handle CLIP-like embeddings well
assert len(labels) == len(X)
mask = labels >= 0
if np.sum(mask) > 0:
n_found_clusters = len(np.unique(labels[mask]))
assert n_found_clusters > 1 # Should find multiple clusters
# Check clustering quality
ari = adjusted_rand_score(y_true[mask], labels[mask])
assert (
ari > 0.15
) # Should achieve reasonable clustering on well-separated data
def test_clustering_on_sentence_transformer_like_embeddings(self):
"""Test clustering on sentence transformer-like 768-dimensional embeddings."""
# Simulate sentence transformer embeddings
np.random.seed(123)
n_samples = 600
n_dims = 768
# Create embeddings with some structure
X = np.random.rand(n_samples, n_dims) - 0.5
# Add some clustering structure
cluster_ids = np.random.choice([0, 1, 2, 3], n_samples)
for i in range(4):
mask = cluster_ids == i
if np.sum(mask) > 0:
# Add cluster-specific signal
X[mask, i * 50 : (i + 1) * 50] += np.random.normal(
2.0, 0.5, (np.sum(mask), 50)
)
# Normalize like sentence transformers
X = X / np.linalg.norm(X, axis=1, keepdims=True)
X = X.astype(np.float32)
clusterer = EVoC(
base_min_cluster_size=8, n_neighbors=20, n_epochs=30, random_state=42
)
labels = clusterer.fit_predict(X)
# Should handle sentence transformer-like embeddings
assert len(labels) == len(X)
mask = labels >= 0
if np.sum(mask) > 0:
n_found_clusters = len(np.unique(labels[mask]))
assert n_found_clusters > 1
def test_clustering_on_quantized_embeddings(self, quantized_embedding_data):
"""Test clustering specifically on quantized int8 embeddings."""
X, y_true = quantized_embedding_data
clusterer = EVoC(
base_n_clusters=4, n_neighbors=12, n_epochs=20, random_state=42
)
labels = clusterer.fit_predict(X)
# Should handle quantized embeddings
assert len(labels) == len(X)
assert np.all((labels >= -1) & (labels < len(X)))
# Check that some clustering structure is found
mask = labels >= 0
if np.sum(mask) > 0:
n_clusters = len(np.unique(labels[mask]))
assert n_clusters >= 1
def test_clustering_on_binary_embeddings(self, binary_embedding_data):
"""Test clustering specifically on binary uint8 embeddings."""
X, y_true = binary_embedding_data
clusterer = EVoC(
base_n_clusters=3, n_neighbors=10, n_epochs=15, random_state=42
)
try:
labels = clusterer.fit_predict(X)
# Should handle binary embeddings
assert len(labels) == len(X)
assert np.all((labels >= -1) & (labels < len(X)))
# Check that some clustering structure is found
mask = labels >= 0
if np.sum(mask) > 0:
n_clusters = len(np.unique(labels[mask]))
assert n_clusters >= 1
except ValueError as e:
# Handle case where clustering fails on binary data
if "empty sequence" in str(e):
pytest.skip(
"Clustering failed on binary embeddings - may need different parameters"
)
if __name__ == "__main__":
pytest.main([__file__])
================================================
FILE: evoc/tests/test_knn_graph.py
================================================
"""
Comprehensive test suite for the knn_graph module.
This module tests the k-nearest neighbor graph construction functionality,
including random projection forest building, nearest neighbor descent,
and the main knn_graph function for different data types.
"""
import numpy as np
import pytest
import time
from unittest.mock import patch
from sklearn.datasets import make_blobs
from sklearn.utils import check_random_state
from evoc.knn_graph import (
ts,
make_forest,
nn_descent,
knn_graph,
INT32_MIN,
INT32_MAX,
)
class TestUtilityFunctions:
"""Test utility functions in the knn_graph module."""
def test_ts_returns_string(self):
"""Test that ts() returns a properly formatted timestamp string."""
timestamp = ts()
assert isinstance(timestamp, str)
assert len(timestamp) > 0
# Test that it's a valid time format by checking it contains expected components
assert any(
month in timestamp
for month in [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
)
def test_ts_consistency(self):
"""Test that ts() returns consistent format across multiple calls."""
timestamp1 = ts()
time.sleep(0.1) # Small delay to potentially get different timestamps
timestamp2 = ts()
# Both should be strings of reasonable length
assert isinstance(timestamp1, str)
assert isinstance(timestamp2, str)
assert len(timestamp1) > 20
assert len(timestamp2) > 20
def test_constants(self):
"""Test that INT32_MIN and INT32_MAX are properly defined."""
assert INT32_MIN == np.iinfo(np.int32).min + 1
assert INT32_MAX == np.iinfo(np.int32).max - 1
assert INT32_MIN < INT32_MAX
class TestMakeForest:
"""Test the make_forest function for different data types and parameters."""
@pytest.fixture
def float_data(self):
"""Create normalized float32 test data."""
np.random.seed(42)
data = np.random.rand(100, 50).astype(np.float32)
# Normalize to unit sphere
norms = np.linalg.norm(data, axis=1, keepdims=True)
data = data / norms
return data
@pytest.fixture
def uint8_data(self):
"""Create uint8 test data."""
np.random.seed(42)
return np.random.randint(0, 256, size=(100, 50), dtype=np.uint8)
@pytest.fixture
def int8_data(self):
"""Create int8 test data."""
np.random.seed(42)
return np.random.randint(-128, 128, size=(100, 50), dtype=np.int8)
def test_make_forest_float32(self, float_data):
"""Test make_forest with float32 data."""
random_state = check_random_state(42)
n_neighbors = 10
n_trees = 4
leaf_size = 20
result = make_forest(
float_data, n_neighbors, n_trees, leaf_size, random_state, np.float32
)
assert isinstance(result, np.ndarray)
assert result.dtype == np.int32
assert result.shape[0] >= n_trees # Should have at least n_trees rows
def test_make_forest_uint8(self, uint8_data):
"""Test make_forest with uint8 data."""
random_state = check_random_state(42)
n_neighbors = 10
n_trees = 4
leaf_size = 20
result = make_forest(
uint8_data, n_neighbors, n_trees, leaf_size, random_state, np.uint8
)
assert isinstance(result, np.ndarray)
assert result.dtype == np.int32
assert result.shape[0] >= n_trees
def test_make_forest_int8(self, int8_data):
"""Test make_forest with int8 data."""
random_state = check_random_state(42)
n_neighbors = 10
n_trees = 4
leaf_size = 20
result = make_forest(
int8_data, n_neighbors, n_trees, leaf_size, random_state, np.int8
)
assert isinstance(result, np.ndarray)
assert result.dtype == np.int32
assert result.shape[0] >= n_trees
def test_make_forest_default_leaf_size(self, float_data):
"""Test make_forest with default leaf_size (None)."""
random_state = check_random_state(42)
n_neighbors = 15
n_trees = 4
result = make_forest(
float_data, n_neighbors, n_trees, None, random_state, np.float32
)
assert isinstance(result, np.ndarray)
assert result.dtype == np.int32
# With default leaf_size, it should be max(10, n_neighbors) = 15
def test_make_forest_different_max_depth(self, float_data):
"""Test make_forest with different max_depth values."""
random_state = check_random_state(42)
n_neighbors = 10
n_trees = 2
leaf_size = 20
# Test with small max_depth
result_shallow = make_forest(
float_data,
n_neighbors,
n_trees,
leaf_size,
random_state,
np.float32,
max_depth=5,
)
# Test with large max_depth
random_state = check_random_state(42) # Reset for consistency
result_deep = make_forest(
float_data,
n_neighbors,
n_trees,
leaf_size,
random_state,
np.float32,
max_depth=500,
)
assert isinstance(result_shallow, np.ndarray)
assert isinstance(result_deep, np.ndarray)
assert result_shallow.dtype == np.int32
assert result_deep.dtype == np.int32
@patch("evoc.knn_graph.make_float_forest")
def test_make_forest_exception_handling(self, mock_make_float_forest, float_data):
"""Test make_forest handles exceptions properly."""
# Mock the forest creation to raise an exception
mock_make_float_forest.side_effect = RuntimeError("Test exception")
random_state = check_random_state(42)
with pytest.warns(
UserWarning, match="Random Projection forest initialisation failed"
):
result = make_forest(float_data, 10, 4, 20, random_state, np.float32)
# Should return empty array on exception
assert isinstance(result, np.ndarray)
assert result.shape == (0, 0)
assert result.dtype == np.int32
class TestNNDescent:
"""Test the nn_descent function for different data types."""
@pytest.fixture
def float_data(self):
"""Create normalized float32 test data."""
np.random.seed(42)
data = np.random.rand(50, 20).astype(np.float32)
norms = np.linalg.norm(data, axis=1, keepdims=True)
data = data / norms
return data
@pytest.fixture
def uint8_data(self):
"""Create uint8 test data."""
np.random.seed(42)
return np.random.randint(0, 256, size=(50, 20), dtype=np.uint8)
@pytest.fixture
def int8_data(self):
"""Create int8 test data."""
np.random.seed(42)
return np.random.randint(-128, 128, size=(50, 20), dtype=np.int8)
def test_nn_descent_float32(self, float_data):
"""Test nn_descent with float32 data."""
rng_state = np.random.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
n_neighbors = 5
with patch("evoc.float_nndescent.nn_descent_float") as mock_nn_descent:
# Mock return value: (indices, distances)
mock_indices = np.random.randint(
0, len(float_data), size=(len(float_data), n_neighbors)
)
mock_distances = -np.random.exponential(
1, size=(len(float_data), n_neighbors)
)
leaf_array = np.random.randint(
0, float_data.shape[0], size=(4, len(float_data)), dtype=np.int32
)
mock_nn_descent.return_value = (mock_indices, mock_distances)
result = nn_descent(
float_data,
n_neighbors,
rng_state,
30,
5,
0.001,
np.float32,
leaf_array=leaf_array,
verbose=False,
)
assert len(result) == 2 # Should return (indices, distances)
assert result[0].shape == (len(float_data), n_neighbors)
assert result[1].shape == (len(float_data), n_neighbors)
# Distances should be transformed: maximum(-log2(-distances), 0.0)
assert np.all(result[1] >= 0.0)
def test_nn_descent_uint8(self, uint8_data):
"""Test nn_descent with uint8 data."""
rng_state = np.random.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
n_neighbors = 5
with patch("evoc.uint8_nndescent.nn_descent_uint8") as mock_nn_descent:
mock_indices = np.random.randint(
0, len(uint8_data), size=(len(uint8_data), n_neighbors)
)
mock_distances = -np.random.exponential(
1, size=(len(uint8_data), n_neighbors)
)
leaf_array = np.random.randint(
0, uint8_data.shape[0], size=(4, len(uint8_data)), dtype=np.int32
)
mock_nn_descent.return_value = (mock_indices, mock_distances)
result = nn_descent(
uint8_data,
n_neighbors,
rng_state,
30,
5,
0.001,
np.uint8,
leaf_array=leaf_array,
verbose=True,
)
assert len(result) == 2
assert result[0].shape == (len(uint8_data), n_neighbors)
assert result[1].shape == (len(uint8_data), n_neighbors)
# Distances should be transformed: -log2(-distances)
def test_nn_descent_int8(self, int8_data):
"""Test nn_descent with int8 data."""
rng_state = np.random.randint(INT32_MIN, INT32_MAX, 3).astype(np.int64)
n_neighbors = 5
with patch("evoc.int8_nndescent.nn_descent_int8") as mock_nn_descent:
mock_indices = np.random.randint(
0, len(int8_data), size=(len(int8_data), n_neighbors)
)
mock_distances = -np.random.exponential(
1, size=(len(int8_data), n_neighbors)
)
mock_nn_descent.return_value = (mock_indices, mock_distances)
leaf_array = np.random.randint(
0, int8_data.shape[0], size=(4, len(int8_data)), dtype=np.int32
)
result = nn_descent(
int8_data,
n_neighbors,
rng_state,
30,
5,
0.001,
np.int8,
leaf_array=leaf_array,
)
assert len(result) == 2
assert result[0].shape == (len(int8_data), n_neighbors)
assert result[1].shape == (len(int8_data), n_neighbors)
# Distances should be transformed: 1.0 / (-distances)
class TestKNNGraph:
"""Test the main knn_graph function."""
@pytest.fixture
def float_test_data(self):
"""Create test data for float32 testing."""
# Create blob data that will be normalized
X, y = make_blobs(
n_samples=200, centers=4, n_features=50, cluster_std=1.0, random_state=42
)
return X.astype(np.float64) # Start with float64 to test conversion
@pytest.fixture
def uint8_test_data(self):
"""Create uint8 test data."""
np.random.seed(42)
return np.random.randint(0, 256, size=(100, 30), dtype=np.uint8)
@pytest.fixture
def int8_test_data(self):
"""Create int8 test data."""
np.random.seed(42)
return np.random.randint(-128, 128, size=(100, 30), dtype=np.int8)
def test_knn_graph_float_data(self, float_test_data):
"""Test knn_graph with float data (gets normalized)."""
result = knn_graph(
float_test_data, n_neighbors=10, n_trees=4, random_state=42, verbose=False
)
assert len(result) == 2 # (indices, distances)
indices, distances = result
assert indices.shape == (len(float_test_data), 10)
assert distances.shape == (len(float_test_data), 10)
assert indices.dtype == np.int32 or indices.dtype == np.int64
assert distances.dtype == np.float32 or distances.dtype == np.float64
# Check that indices are valid
assert np.all(indices >= 0)
assert np.all(indices < len(float_test_data))
# Check that distances are non-negative (after transformation)
assert np.all(distances >= 0.0)
def test_knn_graph_uint8_data(self, uint8_test_data):
"""Test knn_graph with uint8 data."""
result = knn_graph(
uint8_test_data, n_neighbors=5, n_trees=3, random_state=42, verbose=False
)
assert len(result) == 2
indices, distances = result
assert indices.shape == (len(uint8_test_data), 5)
assert distances.shape == (len(uint8_test_data), 5)
assert np.all(indices >= 0)
assert np.all(indices < len(uint8_test_data))
def test_knn_graph_int8_data(self, int8_test_data):
"""Test knn_graph with int8 data."""
result = knn_graph(int8_test_data, n_neighbors=8, random_state=42)
assert len(result) == 2
indices, distances = result
assert indices.shape == (len(int8_test_data), 8)
assert distances.shape == (len(int8_test_data), 8)
assert np.all(indices >= 0)
assert np.all(indices < len(int8_test_data))
def test_knn_graph_parameters(self, float_test_data):
"""Test knn_graph with various parameter combinations."""
# Test with custom parameters
result = knn_graph(
float_test_data,
n_neighbors=15,
n_trees=6,
leaf_size=25,
max_candidates=40,
max_rptree_depth=100,
n_iters=8,
delta=0.01,
n_jobs=1,
verbose=True,
random_state=123,
)
indices, distances = result
assert indices.shape == (len(float_test_data), 15)
assert distances.shape == (len(float_test_data), 15)
def test_knn_graph_default_parameters(self, float_test_data):
"""Test knn_graph with mostly default parameters."""
result = knn_graph(float_test_data, random_state=42)
indices, distances = result
# Default n_neighbors should be 30
assert indices.shape == (len(float_test_data), 30)
assert distances.shape == (len(float_test_data), 30)
def test_knn_graph_n_jobs_setting(self, float_test_data):
"""Test that n_jobs parameter affects numba threading."""
with (
patch("numba.get_num_threads") as mock_get_threads,
patch("numba.set_num_threads") as mock_set_threads,
):
mock_get_threads.return_value = 8
# Test with n_jobs=-1 (should not change threads)
knn_graph(float_test_data, n_jobs=-1, random_state=42)
mock_set_threads.assert_not_called()
# Test with specific n_jobs
knn_graph(float_test_data, n_jobs=4, random_state=42)
# Should be called with 4 and then restored
calls = mock_set_threads.call_args_list
assert any(call[0][0] == 4 for call in calls)
def test_knn_graph_auto_parameters(self, float_test_data):
"""Test automatic parameter selection."""
with patch("numba.get_num_threads", return_value=2):
result = knn_graph(
float_test_data,
n_trees=None, # Should be auto-selected
n_iters=None, # Should be auto-selected
random_state=42,
)
assert len(result) == 2
# Auto n_trees should be max(4, min(8, num_threads)) = 8
# Auto n_iters should be max(5, int(round(log2(n_samples))))
def test_knn_graph_warning_on_failure(self, float_test_data):
"""Test that warning is issued when neighbor finding fails."""
with patch("evoc.knn_graph.nn_descent") as mock_nn_descent:
# Mock a result with some negative indices (indicating failure)
mock_indices = np.full((len(float_test_data), 10), -1, dtype=np.int32)
mock_distances = np.random.rand(len(float_test_data), 10)
mock_nn_descent.return_value = (mock_indices, mock_distances)
with pytest.warns(
UserWarning, match="Failed to correctly find n_neighbors"
):
result = knn_graph(float_test_data, n_neighbors=10, random_state=42)
def test_knn_graph_data_validation(self):
"""Test that knn_graph properly validates input data."""
# Test with invalid data shape
invalid_data = np.array([1, 2, 3]) # 1D array
with pytest.raises((ValueError, TypeError)):
knn_graph(invalid_data)
def test_knn_graph_float_normalization(self):
"""Test that float data gets properly normalized to unit sphere."""
# Create data that's not normalized
data = np.array([[3, 4], [1, 0], [0, 5]], dtype=np.float32)
result = knn_graph(data, n_neighbors=2, random_state=42)
# Should complete without error
assert len(result) == 2
indices, distances = result
assert indices.shape == (3, 2)
assert distances.shape == (3, 2)
def test_knn_graph_zero_norm_handling(self):
"""Test handling of zero-norm vectors in float data."""
# Include a zero vector
data = np.array([[1, 1], [0, 0], [2, 2]], dtype=np.float32)
result = knn_graph(data, n_neighbors=2, random_state=42)
# Should complete without error (zero norms are set to 1.0)
assert len(result) == 2
indices, distances = result
assert indices.shape == (3, 2)
assert distances.shape == (3, 2)
class TestIntegration:
"""Integration tests for the complete knn_graph pipeline."""
def test_small_dataset_complete_pipeline(self):
"""Test complete pipeline on a small dataset."""
# Create a small, well-separated dataset
X, y = make_blobs(
n_samples=50, centers=3, n_features=10, cluster_std=0.5, random_state=42
)
X = X.astype(np.float32)
result = knn_graph(X, n_neighbors=5, n_trees=2, random_state=42, verbose=True)
indices, distances = result
# Basic sanity checks
assert indices.shape == (50, 5)
assert distances.shape == (50, 5)
assert np.all(indices >= 0)
assert np.all(indices < 50)
assert np.all(distances >= 0)
# Note: Points may include themselves as neighbors, which is normal behavior
def test_reproducibility(self):
"""Test that results are reproducible with same random state."""
data = np.random.rand(30, 8).astype(np.float32)
result1 = knn_graph(data, n_neighbors=5, random_state=42)
result2 = knn_graph(data, n_neighbors=5, random_state=42)
np.testing.assert_array_equal(result1[0], result2[0]) # indices
np.testing.assert_array_almost_equal(result1[1], result2[1]) # distances
def test_different_data_types_consistency(self):
"""Test that different data types produce reasonable results."""
# Create base data
np.random.seed(42)
base_data = np.random.rand(40, 20)
# Convert to different types
float_data = base_data.astype(np.float32)
uint8_data = (base_data * 255).astype(np.uint8)
int8_data = ((base_data - 0.5) * 255).astype(np.int8)
# Get results for each type
float_result = knn_graph(float_data, n_neighbors=5, random_state=42)
uint8_result = knn_graph(uint8_data, n_neighbors=5, random_state=42)
int8_result = knn_graph(int8_data, n_neighbors=5, random_state=42)
# All should have same shape
for result in [float_result, uint8_result, int8_result]:
assert result[0].shape == (40, 5)
assert result[1].shape == (40, 5)
assert np.all(result[0] >= 0)
assert np.all(result[0] < 40)
assert np.all(result[1] >= 0)
================================================
FILE: evoc/tests/test_knn_graph_performance.py
================================================
"""
Performance benchmark tests for the knn_graph module.
This module provides performance regression testing for the knn_graph functionality.
The tests are designed to be robust across different hardware configurations by using
relative performance metrics and adaptive thresholds.
"""
import numpy as np
import pytest
import time
import platform
from contextlib import contextmanager
from sklearn.datasets import make_blobs
from typing import Dict, Any, Tuple, List
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
psutil = None
from evoc.knn_graph import knn_graph
class PerformanceMetrics:
"""Class to collect and analyze performance metrics."""
def __init__(self):
self.metrics = {}
self.hardware_info = self._get_hardware_info()
def _get_hardware_info(self) -> Dict[str, Any]:
"""Get basic hardware information for context."""
try:
if HAS_PSUTIL:
return {
"cpu_count": psutil.cpu_count(logical=False),
"cpu_count_logical": psutil.cpu_count(logical=True),
"memory_gb": round(psutil.virtual_memory().total / (1024**3), 2),
"platform": platform.platform(),
"python_version": platform.python_version(),
}
else:
# Fallback without psutil
import os
return {
"cpu_count_logical": os.cpu_count() or 1,
"platform": platform.platform(),
"python_version": platform.python_version(),
"psutil_available": False,
}
except Exception:
return {"error": "Could not gather hardware info"}
def record_metric(self, test_name: str, metric_name: str, value: float):
"""Record a performance metric."""
if test_name not in self.metrics:
self.metrics[test_name] = {}
self.metrics[test_name][metric_name] = value
def get_metric(self, test_name: str, metric_name: str) -> float:
"""Get a recorded metric."""
return self.metrics.get(test_name, {}).get(metric_name, 0.0)
@contextmanager
def time_execution():
"""Context manager to time code execution."""
start_time = time.perf_counter()
yield
end_time = time.perf_counter()
return end_time - start_time
def time_function(func, *args, **kwargs) -> Tuple[Any, float]:
"""Time a function execution and return result and duration."""
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
return result, end_time - start_time
class TestKNNGraphPerformance:
"""Performance tests for knn_graph functionality."""
@pytest.fixture(scope="class")
def perf_metrics(self):
"""Shared performance metrics collector."""
return PerformanceMetrics()
@pytest.fixture(
params=[
(1000, 128), # Small dataset, typical embedding size
(5000, 384), # Medium dataset, larger embedding
(10000, 512), # Large dataset, large embedding
]
)
def dataset_config(self, request):
"""Different dataset configurations for performance testing."""
n_samples, n_features = request.param
return n_samples, n_features
@pytest.fixture
def performance_data(self, dataset_config):
"""Generate performance test data."""
n_samples, n_features = dataset_config
np.random.seed(42) # Consistent data for reproducible benchmarks
# Create clustered data similar to real-world embeddings
X, y = make_blobs(
n_samples=n_samples,
centers=max(4, n_samples // 2000), # Scale centers with data size
n_features=n_features,
cluster_std=0.5,
random_state=42,
)
# Normalize to unit sphere (typical for embeddings)
X = X.astype(np.float32)
norms = np.linalg.norm(X, axis=1, keepdims=True)
X = X / norms
return X, (n_samples, n_features)
def test_knn_graph_scaling_performance(self, performance_data, perf_metrics):
"""Test knn_graph performance scaling with different data sizes."""
X, (n_samples, n_features) = performance_data
test_name = f"knn_graph_scaling_{n_samples}x{n_features}"
# Warm up run (not timed) to ensure compiled numba functions
if n_samples <= 1000: # Only warm up on small data
knn_graph(X[:100], n_neighbors=10, n_trees=2, random_state=42)
# Timed run
result, duration = time_function(
knn_graph, X, n_neighbors=30, n_trees=4, random_state=42, verbose=False
)
# Record metrics
perf_metrics.record_metric(test_name, "duration_seconds", duration)
perf_metrics.record_metric(
test_name, "samples_per_second", n_samples / duration
)
perf_metrics.record_metric(test_name, "n_samples", n_samples)
perf_metrics.record_metric(test_name, "n_features", n_features)
# Verify result is correct
indices, distances = result
assert indices.shape == (n_samples, 30)
assert distances.shape == (n_samples, 30)
# Performance expectations (very loose bounds that should work across hardware)
# These are sanity checks rather than strict requirements
expected_min_samples_per_second = {
1000: 100, # At least 100 samples/sec for small data
5000: 50, # At least 50 samples/sec for medium data
10000: 20, # At least 20 samples/sec for large data
}
min_expected = expected_min_samples_per_second.get(n_samples, 10)
samples_per_sec = n_samples / duration
# Log performance info
print(f"\n{test_name}:")
print(f" Duration: {duration:.3f}s")
print(f" Samples/sec: {samples_per_sec:.1f}")
print(f" Hardware: {perf_metrics.hardware_info}")
# Very loose performance check - mainly to catch major regressions
assert (
samples_per_sec > min_expected
), f"Performance too slow: {samples_per_sec:.1f} < {min_expected} samples/sec"
def test_knn_graph_parameter_performance(self, perf_metrics):
"""Test performance with different parameter configurations."""
np.random.seed(42)
n_samples, n_features = 2000, 256
X, _ = make_blobs(n_samples=n_samples, n_features=n_features, random_state=42)
X = X.astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Test different parameter combinations
param_configs = [
{"n_neighbors": 15, "n_trees": 2, "name": "fast_config"},
{"n_neighbors": 30, "n_trees": 4, "name": "default_config"},
{"n_neighbors": 50, "n_trees": 8, "name": "high_quality_config"},
]
durations = {}
for config in param_configs:
name = config.pop("name")
test_name = f"param_performance_{name}"
# Warm up
knn_graph(
X[:100],
n_neighbors=config["n_neighbors"],
n_trees=config["n_trees"],
random_state=42,
)
# Timed run
result, duration = time_function(knn_graph, X, random_state=42, **config)
durations[name] = duration
perf_metrics.record_metric(test_name, "duration_seconds", duration)
perf_metrics.record_metric(
test_name, "samples_per_second", n_samples / duration
)
print(f"\n{name}: {duration:.3f}s ({n_samples/duration:.1f} samples/sec)")
# Verify relative performance expectations
# The relationship between parameters and performance can be complex
# So we mainly check that all configurations complete successfully
for name, duration in durations.items():
assert duration < 10.0, f"{name} took too long: {duration:.3f}s"
# Optionally log which configuration was fastest
fastest_config = min(durations, key=durations.get)
slowest_config = max(durations, key=durations.get)
print(f"\nFastest: {fastest_config} ({durations[fastest_config]:.3f}s)")
print(f"Slowest: {slowest_config} ({durations[slowest_config]:.3f}s)")
def test_knn_graph_data_type_performance(self, perf_metrics):
"""Test performance differences between data types."""
np.random.seed(42)
n_samples, n_features = 2000, 128
# Generate base data
base_data = np.random.rand(n_samples, n_features)
# Convert to different types
float_data = base_data.astype(np.float32)
uint8_data = (base_data * 255).astype(np.uint8)
int8_data = ((base_data - 0.5) * 255).astype(np.int8)
data_types = [
(float_data, "float32"),
(uint8_data, "uint8"),
(int8_data, "int8"),
]
durations = {}
for data, dtype_name in data_types:
test_name = f"dtype_performance_{dtype_name}"
# Warm up
knn_graph(data[:100], n_neighbors=10, n_trees=2, random_state=42)
# Timed run
result, duration = time_function(
knn_graph,
data,
n_neighbors=20,
n_trees=4,
random_state=42,
verbose=False,
)
durations[dtype_name] = duration
perf_metrics.record_metric(test_name, "duration_seconds", duration)
perf_metrics.record_metric(
test_name, "samples_per_second", n_samples / duration
)
print(
f"\n{dtype_name}: {duration:.3f}s ({n_samples/duration:.1f} samples/sec)"
)
# All should complete in reasonable time
for dtype_name, duration in durations.items():
assert duration < 30.0, f"{dtype_name} took too long: {duration:.3f}s"
def test_knn_graph_threading_performance(self, perf_metrics):
"""Test performance scaling with different thread counts."""
np.random.seed(42)
n_samples, n_features = 3000, 256
X, _ = make_blobs(n_samples=n_samples, n_features=n_features, random_state=42)
X = X.astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Test different thread counts
if HAS_PSUTIL:
max_threads = min(
8, psutil.cpu_count(logical=True)
) # Don't exceed available cores
else:
import os
max_threads = min(8, os.cpu_count() or 1)
thread_counts = [1, max(2, max_threads // 2), max_threads]
durations = {}
for n_jobs in thread_counts:
test_name = f"threading_performance_{n_jobs}_threads"
# Warm up
knn_graph(
X[:100], n_neighbors=10, n_trees=2, n_jobs=n_jobs, random_state=42
)
# Timed run
result, duration = time_function(
knn_graph,
X,
n_neighbors=20,
n_trees=4,
n_jobs=n_jobs,
random_state=42,
verbose=False,
)
durations[n_jobs] = duration
perf_metrics.record_metric(test_name, "duration_seconds", duration)
perf_metrics.record_metric(
test_name, "samples_per_second", n_samples / duration
)
print(
f"\n{n_jobs} threads: {duration:.3f}s ({n_samples/duration:.1f} samples/sec)"
)
# More threads should generally be faster (within reason)
if len(durations) >= 2 and max_threads > 1:
single_thread_time = durations[1]
multi_thread_time = durations[max_threads]
# Allow for some overhead but expect some speedup
speedup_ratio = single_thread_time / multi_thread_time
expected_min_speedup = 1.2 # At least 20% speedup with more threads
print(f"\nSpeedup ratio: {speedup_ratio:.2f}x")
# Only assert if we have multiple cores available
if max_threads > 2:
assert (
speedup_ratio > expected_min_speedup
), f"Multi-threading should provide speedup: {speedup_ratio:.2f}x < {expected_min_speedup}x"
def test_memory_usage_scaling(self, perf_metrics):
"""Test memory usage scaling (basic check)."""
if not HAS_PSUTIL:
pytest.skip("psutil not available for memory testing")
import gc
# Get baseline memory
gc.collect()
process = psutil.Process()
baseline_memory = process.memory_info().rss / 1024 / 1024 # MB
test_sizes = [(1000, 64), (2000, 64), (4000, 64)]
memory_usages = []
for n_samples, n_features in test_sizes:
gc.collect()
# Generate data
np.random.seed(42)
X, _ = make_blobs(
n_samples=n_samples, n_features=n_features, random_state=42
)
X = X.astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Run knn_graph
before_memory = process.memory_info().rss / 1024 / 1024
result = knn_graph(
X, n_neighbors=20, n_trees=4, random_state=42, verbose=False
)
after_memory = process.memory_info().rss / 1024 / 1024
memory_increase = after_memory - baseline_memory
memory_usages.append((n_samples, memory_increase))
test_name = f"memory_usage_{n_samples}_samples"
perf_metrics.record_metric(test_name, "memory_mb", memory_increase)
print(f"\n{n_samples} samples: {memory_increase:.1f} MB")
# Clean up
del X, result
gc.collect()
# Memory usage should scale reasonably (not exponentially)
if len(memory_usages) >= 2:
small_n, small_mem = memory_usages[0]
large_n, large_mem = memory_usages[-1]
sample_ratio = large_n / small_n
memory_ratio = large_mem / max(small_mem, 1.0) # Avoid division by zero
# Memory should not grow faster than O(n^2)
assert (
memory_ratio < sample_ratio**1.5
), f"Memory usage growing too fast: {memory_ratio:.2f}x for {sample_ratio:.2f}x samples"
def test_reproducibility_performance(self, perf_metrics):
"""Test that performance is consistent across runs."""
np.random.seed(42)
n_samples, n_features = 1500, 128
X, _ = make_blobs(n_samples=n_samples, n_features=n_features, random_state=42)
X = X.astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Warm up
knn_graph(X[:100], n_neighbors=10, n_trees=2, random_state=42)
# Run multiple times
n_runs = 3
durations = []
for i in range(n_runs):
result, duration = time_function(
knn_graph,
X,
n_neighbors=20,
n_trees=4,
random_state=42, # Same random state for consistency
verbose=False,
)
durations.append(duration)
# Calculate statistics
mean_duration = np.mean(durations)
std_duration = np.std(durations)
cv = std_duration / mean_duration # Coefficient of variation
perf_metrics.record_metric("reproducibility", "mean_duration", mean_duration)
perf_metrics.record_metric("reproducibility", "std_duration", std_duration)
perf_metrics.record_metric("reproducibility", "coefficient_of_variation", cv)
print(f"\nReproducibility test:")
print(f" Mean duration: {mean_duration:.3f}s")
print(f" Std deviation: {std_duration:.3f}s")
print(f" Coefficient of variation: {cv:.3f}")
# Performance should be reasonably consistent
# Allow for up to 20% variation between runs
assert cv < 0.4, f"Performance too variable: CV = {cv:.3f}"
# Verify results are identical
result1, _ = time_function(knn_graph, X, n_neighbors=10, random_state=42)
result2, _ = time_function(knn_graph, X, n_neighbors=10, random_state=42)
np.testing.assert_array_equal(result1[0], result2[0])
np.testing.assert_array_almost_equal(result1[1], result2[1])
@pytest.mark.performance
class TestPerformanceRegression:
"""Performance regression tests with historical baselines."""
def test_baseline_performance_check(self):
"""
Baseline performance test that can be used to establish performance standards.
This test should be run on a reference machine to establish baseline timings,
and then used in CI to detect significant regressions.
"""
np.random.seed(42)
# Standard test case
n_samples, n_features = 5000, 256
X, _ = make_blobs(n_samples=n_samples, n_features=n_features, random_state=42)
X = X.astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)
# Warm up
knn_graph(X[:100], n_neighbors=10, n_trees=2, random_state=42)
# Benchmark run
start_time = time.perf_counter()
result = knn_graph(X, n_neighbors=30, n_trees=4, random_state=42, verbose=False)
duration = time.perf_counter() - start_time
indices, distances = result
samples_per_second = n_samples / duration
print(f"\nBaseline Performance Report:")
print(f" Dataset: {n_samples} samples x {n_features} features")
print(f" Duration: {duration:.3f} seconds")
print(f" Throughput: {samples_per_second:.1f} samples/second")
print(f" Hardware: {platform.platform()}")
if HAS_PSUTIL:
print(f" CPU cores: {psutil.cpu_count(logical=True)}")
print(f" Memory: {psutil.virtual_memory().total / (1024**3):.1f} GB")
else:
import os
print(f" CPU cores: {os.cpu_count() or 'unknown'}")
print(f" Memory: unknown (psutil not available)")
# Basic sanity checks
assert indices.shape == (n_samples, 30)
assert distances.shape == (n_samples, 30)
assert np.all(indices >= 0)
assert np.all(distances >= 0)
# Very basic performance floor (should work on any reasonable hardware)
min_samples_per_second = 10 # Very conservative
assert (
samples_per_second > min_samples_per_second
), f"Performance below minimum threshold: {samples_per_second:.1f} < {min_samples_per_second}"
# Store baseline for potential future comparison
# In a real CI system, you might save this to a file or database
baseline_info = {
"duration": duration,
"samples_per_second": samples_per_second,
"hardware_hash": hash(platform.platform()),
"timestamp": time.time(),
}
# Note: baseline_info could be used for comparison in CI systems
# but we don't return it to avoid pytest warnings
================================================
FILE: evoc/tests/test_numba_kdtree.py
================================================
"""
Test suite for NumbaKDTree compatibility with sklearn KDTree.
This module tests that our NumbaKDTree implementation produces equivalent
partitioning and query results compared to sklearn's KDTree implementation.
"""
import numpy as np
import pytest
import numba
from sklearn.neighbors import KDTree as SklearnKDTree
from evoc.numba_kdtree import build_kdtree
class TestKDTreeCompatibility:
"""Test compatibility between NumbaKDTree and sklearn KDTree implementations."""
@pytest.fixture(
params=[
(50, 2), # Small 2D
(100, 3), # Medium 3D
(200, 5), # Large 5D
(500, 8), # Large 8D
]
)
def test_data(self, request):
"""Generate test data for various configurations."""
n_samples, n_features = request.param
np.random.seed(42) # Fixed seed for reproducible tests
return np.random.rand(n_samples, n_features).astype(np.float32)
@pytest.fixture(params=[10, 20, 40])
def leaf_size(self, request):
"""Test different leaf sizes."""
return request.param
def test_tree_structure_compatibility(self, test_data, leaf_size):
"""Test that tree structures have compatible shapes and properties."""
# Build trees
sklearn_tree = SklearnKDTree(test_data, leaf_size=leaf_size)
numba_tree = build_kdtree(test_data, leaf_size=leaf_size)
# Get sklearn internal arrays
sk_data, sk_idx_array, sk_node_data, sk_node_bounds = sklearn_tree.get_arrays()
# Test data compatibility
assert np.array_equal(sk_data, numba_tree.data), "Data arrays should match"
assert (
sk_idx_array.shape == numba_tree.idx_array.shape
), "Index array shapes should match"
# Test node data shapes
assert (
sk_node_data["idx_start"].shape == numba_tree.idx_start.shape
), "idx_start shapes should match"
assert (
sk_node_data["idx_end"].shape == numba_tree.idx_end.shape
), "idx_end shapes should match"
assert (
sk_node_data["radius"].shape == numba_tree.radius.shape
), "radius shapes should match"
assert (
sk_node_data["is_leaf"].shape == numba_tree.is_leaf.shape
), "is_leaf shapes should match"
# Test node bounds shape
assert (
sk_node_bounds.shape == numba_tree.node_bounds.shape
), "Node bounds shapes should match"
def test_node_partitioning_equivalence(self, test_data, leaf_size):
"""
Test that both implementations partition data into equivalent node sets.
This verifies that each node contains the same set of data points,
regardless of internal ordering differences.
"""
# Build trees
sklearn_tree = SklearnKDTree(test_data, leaf_size=leaf_size)
numba_tree = build_kdtree(test_data, leaf_size=leaf_size)
# Get sklearn internal arrays
sk_data, sk_idx_array, sk_node_data, sk_node_bounds = sklearn_tree.get_arrays()
n_nodes = sk_node_data.shape[0]
matches = 0
total_comparisons = 0
for node in range(n_nodes):
# Get node boundaries
sk_start = sk_node_data[node]["idx_start"]
sk_end = sk_node_data[node]["idx_end"]
sk_is_leaf = sk_node_data[node]["is_leaf"]
nb_start = numba_tree.idx_start[node]
nb_end = numba_tree.idx_end[node]
nb_is_leaf = numba_tree.is_leaf[node]
# Node properties should match exactly
assert sk_start == nb_start, f"Node {node}: idx_start mismatch"
assert sk_end == nb_end, f"Node {node}: idx_end mismatch"
assert sk_is_leaf == nb_is_leaf, f"Node {node}: is_leaf mismatch"
# Skip empty nodes
if sk_start >= sk_end:
continue
total_comparisons += 1
# Get indices for this node and sort them (to ignore ordering differences)
sk_indices = np.sort(sk_idx_array[sk_start:sk_end])
nb_indices = np.sort(numba_tree.idx_array[nb_start:nb_end])
# The sorted indices should be identical
if np.array_equal(sk_indices, nb_indices):
matches += 1
# Require high compatibility (allowing for minor algorithmic differences)
match_rate = matches / total_comparisons if total_comparisons > 0 else 1.0
assert (
match_rate >= 0.95
), f"Node partitioning match rate {match_rate:.1%} is below 95% threshold"
def test_data_ordering_equivalence(self, test_data, leaf_size):
"""
Test that data ordering along split axes is equivalent.
This is a more fundamental test of whether the partitioning logic
is working similarly between implementations.
"""
# Build trees
sklearn_tree = SklearnKDTree(test_data, leaf_size=leaf_size)
numba_tree = build_kdtree(test_data, leaf_size=leaf_size)
# Get sklearn internal arrays
sk_data, sk_idx_array, sk_node_data, sk_node_bounds = sklearn_tree.get_arrays()
n_nodes = sk_node_data.shape[0]
axis_ordering_matches = 0
total_internal_nodes = 0
for node in range(n_nodes):
# Only check internal nodes (non-leaf nodes)
if sk_node_data[node]["is_leaf"]:
continue
total_internal_nodes += 1
# Get node boundaries
sk_start = sk_node_data[node]["idx_start"]
sk_end = sk_node_data[node]["idx_end"]
# Skip if insufficient points
if sk_end - sk_start < 2:
continue
# Get indices for both implementations
sk_indices = sk_idx_array[sk_start:sk_end]
nb_indices = numba_tree.idx_array[sk_start:sk_end]
# Find split axis (dimension with maximum spread)
spreads = []
for axis in range(test_data.shape[1]):
sk_values = test_data[sk_indices, axis]
min_val, max_val = np.min(sk_values), np.max(sk_values)
spreads.append(max_val - min_val)
split_axis = np.argmax(spreads)
# Get data values along split axis
sk_axis_values = test_data[sk_indices, split_axis]
nb_axis_values = test_data[nb_indices, split_axis]
# Check if the median/partition point is similar
sk_median = np.median(sk_axis_values)
nb_median = np.median(nb_axis_values)
# Count points on each side of median
sk_left_count = np.sum(sk_axis_values <= sk_median)
sk_right_count = np.sum(sk_axis_values > sk_median)
nb_left_count = np.sum(nb_axis_values <= nb_median)
nb_right_count = np.sum(nb_axis_values > nb_median)
# Check if partitioning is roughly equivalent
# (allowing for different tie-breaking in median calculation)
partitioning_similar = (
abs(sk_left_count - nb_left_count) <= 2
and abs(sk_right_count - nb_right_count) <= 2
)
if partitioning_similar:
axis_ordering_matches += 1
# Require high compatibility for data ordering
ordering_match_rate = (
axis_ordering_matches / total_internal_nodes
if total_internal_nodes > 0
else 1.0
)
assert (
ordering_match_rate >= 0.80
), f"Data ordering match rate {ordering_match_rate:.1%} is below 80% threshold"
def test_query_results_compatibility(self, test_data, leaf_size):
"""Test that query results are equivalent between implementations."""
# Build trees
sklearn_tree = SklearnKDTree(test_data, leaf_size=leaf_size)
numba_tree = build_kdtree(test_data, leaf_size=leaf_size)
# Create query points (subset of original data for deterministic results)
np.random.seed(123)
query_indices = np.random.choice(
len(test_data), size=min(10, len(test_data)), replace=False
)
query_data = test_data[query_indices]
k = min(5, len(test_data)) # Number of neighbors
# Query sklearn tree
sk_distances, sk_indices = sklearn_tree.query(
query_data, k=k, return_distance=True
)
# Query numba tree using the parallel implementation
from evoc.numba_kdtree import parallel_tree_query
nb_distances, nb_indices = parallel_tree_query(
numba_tree,
query_data,
k=numba.int64(k),
output_rdist=numba.types.boolean(False),
)
# Results should be very similar (allowing for minor floating point differences)
# Sort both results by indices to handle any ordering differences
for i in range(len(query_data)):
# Sort by indices to compare equivalent sets
sk_sorted_idx = np.argsort(sk_indices[i])
nb_sorted_idx = np.argsort(nb_indices[i])
sk_sorted_indices = sk_indices[i][sk_sorted_idx]
nb_sorted_indices = nb_indices[i][nb_sorted_idx]
sk_sorted_distances = sk_distances[i][sk_sorted_idx]
nb_sorted_distances = nb_distances[i][nb_sorted_idx]
# Check that we get the same nearest neighbors
np.testing.assert_array_equal(
sk_sorted_indices,
nb_sorted_indices,
err_msg=f"Query {i}: Nearest neighbor indices don't match",
)
# Check that distances are very close
np.testing.assert_allclose(
sk_sorted_distances,
nb_sorted_distances,
rtol=1e-5,
atol=1e-6,
err_msg=f"Query {i}: Distances don't match within tolerance",
)
def test_tree_bounds_compatibility(self, test_data, leaf_size):
"""Test that node bounds are calculated consistently."""
# Build trees
sklearn_tree = SklearnKDTree(test_data, leaf_size=leaf_size)
numba_tree = build_kdtree(test_data, leaf_size=leaf_size)
# Get sklearn bounds
sk_data, sk_idx_array, sk_node_data, sk_node_bounds = sklearn_tree.get_arrays()
# Node bounds should match closely
np.testing.assert_allclose(
sk_node_bounds,
numba_tree.node_bounds,
rtol=1e-5,
atol=1e-6,
err_msg="Node bounds don't match between implementations",
)
class TestKDTreeEdgeCases:
"""Test edge cases and special conditions."""
def test_single_point(self):
"""Test with a single data point."""
data = np.array([[1.0, 2.0, 3.0]], dtype=np.float32)
sklearn_tree = SklearnKDTree(data, leaf_size=1)
numba_tree = build_kdtree(data, leaf_size=1)
# Should handle single point gracefully
assert numba_tree.data.shape == (1, 3)
assert numba_tree.idx_array.shape == (1,)
def test_duplicate_points(self):
"""Test with duplicate data points."""
data = np.array(
[
[1.0, 2.0],
[1.0, 2.0], # Duplicate
[3.0, 4.0],
[1.0, 2.0], # Another duplicate
],
dtype=np.float32,
)
sklearn_tree = SklearnKDTree(data, leaf_size=2)
numba_tree = build_kdtree(data, leaf_size=2)
# Should handle duplicates without error
assert numba_tree.data.shape == data.shape
# Query should work with duplicates
from evoc.numba_kdtree import parallel_tree_query
distances, indices = parallel_tree_query(
numba_tree, data[:1], k=2, output_rdist=False
)
assert distances.shape == (1, 2)
assert indices.shape == (1, 2)
def test_high_dimensional_data(self):
"""Test with high-dimensional data."""
np.random.seed(42)
data = np.random.rand(100, 50).astype(np.float32) # 50D data
sklearn_tree = SklearnKDTree(data, leaf_size=10)
numba_tree = build_kdtree(data, leaf_size=10)
# Should handle high dimensions
assert numba_tree.data.shape == (100, 50)
# Quick query test
from evoc.numba_kdtree import parallel_tree_query
distances, indices = parallel_tree_query(
numba_tree, data[:5], k=3, output_rdist=False
)
assert distances.shape == (5, 3)
assert indices.shape == (5, 3)
# Integration test that can be run standalone
def test_full_pipeline_compatibility():
"""Integration test ensuring the full pipeline works with both tree types."""
np.random.seed(42)
data = np.random.rand(200, 5).astype(np.float32)
# Build numba tree and run boruvka (this was the original failing case)
from evoc.numba_kdtree import build_kdtree
from evoc.boruvka import parallel_boruvka
tree = build_kdtree(data, leaf_size=20)
num_threads = numba.get_num_threads()
# This should not raise any numba errors
edges = parallel_boruvka(
tree, n_threads=num_threads, min_samples=5, reproducible=True
)
# Should produce reasonable results
assert len(edges) > 0, "Boruvka should produce some edges"
assert edges.shape[1] == 3, "Edges should have 3 columns (from, to, weight)"
assert np.all(edges[:, 2] >= 0), "Edge weights should be non-negative"
if __name__ == "__main__":
# Allow running as a script for quick testing
pytest.main([__file__, "-v"])
================================================
FILE: evoc/tests/test_numba_kdtree_performance.py
================================================
"""
Performance benchmark tests for the numba_kdtree module.
This module provides performance regression testing and comparison benchmarks
against sklearn's KDTree implementation. The numba implementation is optimized
for large query batches where parallelization benefits outweigh overhead.
Key performance characteristics:
- Small batches (<1000 queries): May be slower due to parallelization overhead
- Medium batches (1000-3000 queries): Competitive to slightly faster
- Large batches (3000+ queries): Significant speedup (3-20x) due to parallelization
- Ultra-large batches (10k+ queries): Maximum speedup, ideal use case
The tests focus on large query batch scenarios since that is the primary
optimization target for the numba implementation.
"""
import numpy as np
import pytest
import time
import platform
from contextlib import contextmanager
from sklearn.datasets import make_blobs
from sklearn.neighbors import KDTree as SklearnKDTree
from typing import Dict, Any, Tuple, List
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
psutil = None
from evoc.numba_kdtree import build_kdtree, parallel_tree_query, kdtree_to_numba
def time_function(func, *args, **kwargs) -> Tuple[Any, float]:
"""Time a function execution and return result and duration."""
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
return result, end_time - start_time
class KDTreePerformanceMetrics:
"""Class to collect and analyze KDTree performance metrics."""
def __init__(self):
self.metrics = {}
self.hardware_info = self._get_hardware_info()
def _get_hardware_info(self) -> Dict[str, Any]:
"""Get basic hardware information for context."""
try:
if HAS_PSUTIL:
return {
"cpu_count": psutil.cpu_count(logical=False),
"cpu_count_logical": psutil.cpu_count(logical=True),
"memory_gb": round(psutil.virtual_memory().total / (1024**3), 2),
"platform": platform.platform(),
"python_version": platform.python_version(),
}
else:
import os
return {
"cpu_count_logical": os.cpu_count() or 1,
"platform": platform.platform(),
"python_version": platform.python_version(),
"psutil_available": False,
}
except Exception:
return {"error": "Could not gather hardware info"}
def record_metric(self, test_name: str, metric_name: str, value: float):
"""Record a performance metric."""
if test_name not in self.metrics:
self.metrics[test_name] = {}
self.metrics[test_name][metric_name] = value
def get_metric(self, test_name: str, metric_name: str) -> float:
"""Get a recorded metric."""
return self.metrics.get(test_name, {}).get(metric_name, 0.0)
@pytest.mark.performance
class TestKDTreePerformance:
"""Performance tests for numba KDTree implementation."""
@pytest.fixture(scope="class")
def perf_metrics(self):
"""Shared performance metrics collector."""
return KDTreePerformanceMetrics()
@pytest.fixture(
params=[
(1000, 2), # Small 2D dataset
(5000, 3), # Medium 3D dataset
(10000, 5), # Large 5D dataset
(20000, 8), # Very large 8D dataset
]
)
def dataset_config(self, request):
"""Different dataset configurations for performance testing."""
n_samples, n_features = request.param
return n_samples, n_features
@pytest.fixture
def performance_data(self, dataset_config):
"""Generate performance test data."""
n_samples, n_features = dataset_config
np.random.seed(42) # Consistent data for reproducible benchmarks
# Create diverse data that exercises different tree structures
if n_features <= 3:
# Use blobs for low-dimensional data
X, y = make_blobs(
n_samples=n_samples,
centers=max(4, n_samples // 1000),
n_features=n_features,
cluster_std=1.0,
random_state=42,
)
else:
# Use uniform random for higher dimensions
X = np.random.rand(n_samples, n_features) * 10.0
X = X.astype(np.float32)
return X, (n_samples, n_features)
def test_kdtree_construction_performance(self, performance_data, perf_metrics):
"""Compare KDTree construction performance: Numba vs Sklearn."""
X, (n_samples, n_features) = performance_data
test_name = f"construction_{n_samples}x{n_features}"
# Warm up numba compilation (not timed)
if n_samples >= 1000:
warmup_data = X[:100].copy()
build_kdtree(warmup_data, leaf_size=10)
# Test sklearn construction
sklearn_tree, sklearn_time = time_function(SklearnKDTree, X, leaf_size=40)
# Test numba construction
numba_tree, numba_time = time_function(build_kdtree, X, leaf_size=40)
# Record metrics
perf_metrics.record_metric(test_name, "sklearn_construction_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_construction_time", numba_time)
perf_metrics.record_metric(
test_name, "construction_speedup", sklearn_time / numba_time
)
perf_metrics.record_metric(test_name, "n_samples", n_samples)
perf_metrics.record_metric(test_name, "n_features", n_features)
# Calculate throughput
sklearn_throughput = n_samples / sklearn_time
numba_throughput = n_samples / numba_time
print(f"\n{test_name} Construction Performance:")
print(f" Sklearn: {sklearn_time:.4f}s ({sklearn_throughput:.0f} samples/sec)")
print(f" Numba: {numba_time:.4f}s ({numba_throughput:.0f} samples/sec)")
print(f" Speedup: {sklearn_time/numba_time:.2f}x")
# Verify both trees work correctly
query_point = X[0:1]
sklearn_dists, sklearn_inds = sklearn_tree.query(query_point, k=5)
numba_dists, numba_inds = parallel_tree_query(
numba_tree, query_point, k=5, output_rdist=False
)
assert sklearn_dists.shape == (1, 5)
assert numba_dists.shape == (1, 5)
assert sklearn_inds.shape == (1, 5)
assert numba_inds.shape == (1, 5)
# Performance expectations
# After warmup, numba should be competitive or better
if (
n_samples >= 1000
): # Only assert on larger datasets where speedup is more likely
assert (
numba_time < sklearn_time * 2.0
), f"Numba construction too slow: {numba_time:.4f}s vs sklearn {sklearn_time:.4f}s"
def test_kdtree_query_performance_large_batch(self, performance_data, perf_metrics):
"""Compare large batch query performance: Numba vs Sklearn (optimized use case)."""
X, (n_samples, n_features) = performance_data
test_name = f"query_large_batch_{n_samples}x{n_features}"
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Prepare large query batch - this is where numba should excel
np.random.seed(123)
# Use large query sets that benefit from parallelization
n_queries = max(1000, n_samples // 2) # Large query batches
query_data = np.random.rand(n_queries, n_features).astype(np.float32) * 10.0
k = min(30, n_samples // 20) # Reasonable k value
# Warm up numba (not timed)
_ = parallel_tree_query(numba_tree, query_data[:5], k=k, output_rdist=False)
# Time sklearn queries
sklearn_result, sklearn_time = time_function(
sklearn_tree.query, query_data, k=k
)
# Time numba queries
numba_result, numba_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
# Record metrics
perf_metrics.record_metric(test_name, "sklearn_query_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_query_time", numba_time)
perf_metrics.record_metric(
test_name, "query_speedup", sklearn_time / numba_time
)
perf_metrics.record_metric(
test_name, "queries_per_second_sklearn", n_queries / sklearn_time
)
perf_metrics.record_metric(
test_name, "queries_per_second_numba", n_queries / numba_time
)
sklearn_qps = n_queries / sklearn_time
numba_qps = n_queries / numba_time
print(
f"\n{test_name} Large Batch Query Performance ({n_queries} queries, k={k}):"
)
print(f" Sklearn: {sklearn_time:.4f}s ({sklearn_qps:.0f} queries/sec)")
print(f" Numba: {numba_time:.4f}s ({numba_qps:.0f} queries/sec)")
print(f" Speedup: {sklearn_time/numba_time:.2f}x")
# Verify results have correct shape
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == (n_queries, k)
assert numba_dists.shape == (n_queries, k)
assert sklearn_inds.shape == (n_queries, k)
assert numba_inds.shape == (n_queries, k)
# Performance expectations for large batches
# Numba should excel with large query sets due to parallelization
# But only assert performance for sufficiently large batches where parallelization benefit outweighs overhead
if (
n_queries >= 3000
): # Only assert performance for large enough batches where advantage is consistent
assert (
numba_time < sklearn_time * 1.0
), f"Numba queries too slow for large batch ({n_queries} queries): {numba_time:.4f}s vs sklearn {sklearn_time:.4f}s"
# For large query batches, expect significant speedup
assert (
sklearn_time / numba_time > 1.0
), f"Expected numba advantage for large batches ({n_queries} queries): {sklearn_time/numba_time:.2f}x speedup"
elif n_queries >= 2000: # Medium-large batches should show some advantage
assert (numba_time < sklearn_time * 1.0) or (
numba_time < 0.05
), f"Numba queries too slow for medium-large batch ({n_queries} queries): {numba_time:.4f}s vs sklearn {sklearn_time:.4f}s"
# Some speedup expected but can be variable
assert (sklearn_time / numba_time > 1.0) or (
numba_time < 0.05
), f"Expected at least equal performance for medium-large batches ({n_queries} queries): {sklearn_time/numba_time:.2f}x speedup"
else:
# For smaller batches, just ensure numba is not excessively slow (parallelization overhead is acceptable)
# More lenient threshold to handle hardware variability in CI environments
assert (
numba_time < sklearn_time * 4.0
), f"Numba queries excessively slow for batch ({n_queries} queries): {numba_time:.4f}s vs sklearn {sklearn_time:.4f}s"
def test_kdtree_query_performance_massive_batch(
self, performance_data, perf_metrics
):
"""Compare massive batch query performance to test maximum parallelization benefits."""
X, (n_samples, n_features) = performance_data
test_name = f"query_massive_batch_{n_samples}x{n_features}"
# Skip small datasets for massive batch testing
if n_samples < 5000:
pytest.skip("Massive batch testing not meaningful for small datasets")
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Prepare very large batch of queries - this should show maximum numba advantage
np.random.seed(124)
n_queries = max(
5000, n_samples
) # Very large batch - equal or larger than training set
query_data = np.random.rand(n_queries, n_features).astype(np.float32) * 10.0
k = min(50, n_samples // 20) # Larger k value
# Warm up numba
_ = parallel_tree_query(numba_tree, query_data[:10], k=k, output_rdist=False)
# Time sklearn batch queries
sklearn_result, sklearn_time = time_function(
sklearn_tree.query, query_data, k=k
)
# Time numba batch queries (should benefit from parallelization)
numba_result, numba_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
# Record metrics
perf_metrics.record_metric(test_name, "sklearn_batch_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_batch_time", numba_time)
perf_metrics.record_metric(
test_name, "batch_speedup", sklearn_time / numba_time
)
perf_metrics.record_metric(
test_name, "batch_queries_per_second_sklearn", n_queries / sklearn_time
)
perf_metrics.record_metric(
test_name, "batch_queries_per_second_numba", n_queries / numba_time
)
sklearn_qps = n_queries / sklearn_time
numba_qps = n_queries / numba_time
print(
f"\n{test_name} Massive Batch Query Performance ({n_queries} queries, k={k}):"
)
print(f" Sklearn: {sklearn_time:.4f}s ({sklearn_qps:.0f} queries/sec)")
print(f" Numba: {numba_time:.4f}s ({numba_qps:.0f} queries/sec)")
print(f" Speedup: {sklearn_time/numba_time:.2f}x")
# Verify results
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == (n_queries, k)
assert numba_dists.shape == (n_queries, k)
# For massive batch queries, numba should show significant advantage
assert (
numba_time < sklearn_time * 1.2
), f"Numba massive batch queries should be faster: {numba_time:.4f}s vs sklearn {sklearn_time:.4f}s"
# Expect substantial speedup on massive batches (this is the target use case)
# More conservative threshold to handle hardware variability
assert (
sklearn_time / numba_time > 0.85
), f"Expected significant numba advantage for massive batches ({n_queries} queries): {sklearn_time/numba_time:.2f}x"
def test_kdtree_accuracy_comparison(self, performance_data, perf_metrics):
"""Verify that numba KDTree results match sklearn results."""
X, (n_samples, n_features) = performance_data
test_name = f"accuracy_{n_samples}x{n_features}"
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Test on a subset of data points as queries
np.random.seed(125)
query_indices = np.random.choice(
n_samples, size=min(50, n_samples), replace=False
)
query_data = X[query_indices]
k = min(5, n_samples // 10)
# Get results from both implementations
sklearn_dists, sklearn_inds = sklearn_tree.query(query_data, k=k)
numba_dists, numba_inds = parallel_tree_query(
numba_tree, query_data, k=k, output_rdist=False
)
# Check shapes match
assert sklearn_dists.shape == numba_dists.shape
assert sklearn_inds.shape == numba_inds.shape
# Check that distances are reasonable (all finite, non-negative)
assert np.all(np.isfinite(sklearn_dists))
assert np.all(np.isfinite(numba_dists))
assert np.all(sklearn_dists >= 0)
assert np.all(numba_dists >= 0)
# Check that indices are valid
assert np.all(sklearn_inds >= 0)
assert np.all(sklearn_inds < n_samples)
assert np.all(numba_inds >= 0)
assert np.all(numba_inds < n_samples)
# For the first neighbor (should be identical for deterministic data)
# Allow some tolerance due to potential floating point differences
first_neighbor_distance_diff = np.abs(sklearn_dists[:, 0] - numba_dists[:, 0])
max_distance_diff = np.max(first_neighbor_distance_diff)
print(f"\n{test_name} Accuracy Check:")
print(f" Max first neighbor distance difference: {max_distance_diff:.6f}")
print(
f" Mean distance difference: {np.mean(first_neighbor_distance_diff):.6f}"
)
# Allow small numerical differences
assert (
max_distance_diff < 1e-5
), f"Distance differences too large: {max_distance_diff:.6f}"
# Check that most nearest neighbors are the same
first_neighbor_matches = np.sum(sklearn_inds[:, 0] == numba_inds[:, 0])
match_rate = first_neighbor_matches / len(query_data)
print(f" First neighbor match rate: {match_rate:.2%}")
# Should have high agreement on nearest neighbors
assert (
match_rate > 0.95
), f"Nearest neighbor agreement too low: {match_rate:.2%}"
def test_kdtree_scaling_performance(self, perf_metrics):
"""Test how performance scales with dataset size."""
np.random.seed(42)
sizes = [1000, 2000, 5000, 10000]
n_features = 5
sklearn_times = []
numba_times = []
for n_samples in sizes:
# Generate test data
X = np.random.rand(n_samples, n_features).astype(np.float32) * 10.0
# Warm up numba
if n_samples >= 1000:
warmup_tree = build_kdtree(X[:100], leaf_size=40)
_ = parallel_tree_query(warmup_tree, X[:10], k=5, output_rdist=False)
# Time construction
sklearn_tree, sklearn_time = time_function(SklearnKDTree, X, leaf_size=40)
numba_tree, numba_time = time_function(build_kdtree, X, leaf_size=40)
sklearn_times.append(sklearn_time)
numba_times.append(numba_time)
# Record metrics
test_name = f"scaling_{n_samples}"
perf_metrics.record_metric(test_name, "sklearn_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_time", numba_time)
perf_metrics.record_metric(test_name, "speedup", sklearn_time / numba_time)
print(f"\nScaling test {n_samples} samples:")
print(f" Sklearn: {sklearn_time:.4f}s")
print(f" Numba: {numba_time:.4f}s")
print(f" Speedup: {sklearn_time/numba_time:.2f}x")
# Check scaling behavior
# Construction time should scale sub-quadratically
for i in range(1, len(sizes)):
size_ratio = sizes[i] / sizes[i - 1]
sklearn_time_ratio = sklearn_times[i] / sklearn_times[i - 1]
numba_time_ratio = numba_times[i] / numba_times[i - 1]
# Time should not scale worse than O(n^1.5)
max_expected_ratio = size_ratio**1.5
assert (
sklearn_time_ratio < max_expected_ratio * 2
), f"Sklearn scaling too poor: {sklearn_time_ratio:.2f}x for {size_ratio:.2f}x data"
assert (
numba_time_ratio < max_expected_ratio * 2
), f"Numba scaling too poor: {numba_time_ratio:.2f}x for {size_ratio:.2f}x data"
def test_kdtree_different_k_values(self, perf_metrics):
"""Test performance with different k values."""
np.random.seed(42)
n_samples, n_features = 5000, 4
X = np.random.rand(n_samples, n_features).astype(np.float32) * 10.0
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Test queries with large batch
n_queries = 2000 # Large batch to benefit from parallelization
query_data = np.random.rand(n_queries, n_features).astype(np.float32) * 10.0
# Warm up numba
_ = parallel_tree_query(numba_tree, query_data[:5], k=5, output_rdist=False)
k_values = [1, 5, 10, 20, 50]
for k in k_values:
if k >= n_samples:
continue
# Time both implementations
sklearn_result, sklearn_time = time_function(
sklearn_tree.query, query_data, k=k
)
numba_result, numba_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
test_name = f"k_value_{k}"
perf_metrics.record_metric(test_name, "sklearn_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_time", numba_time)
perf_metrics.record_metric(test_name, "speedup", sklearn_time / numba_time)
print(f"\nk={k} performance:")
print(f" Sklearn: {sklearn_time:.4f}s")
print(f" Numba: {numba_time:.4f}s")
print(f" Speedup: {sklearn_time/numba_time:.2f}x")
# Verify correctness
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == (n_queries, k)
assert numba_dists.shape == (n_queries, k)
# Performance should be reasonable for all k values
assert (
numba_time < sklearn_time * 3.0
), f"Numba too slow for k={k}: {sklearn_time/numba_time:.2f}x"
def test_kdtree_query_batch_scaling(self, perf_metrics):
"""Test how query performance scales with batch size (numba's sweet spot)."""
np.random.seed(42)
n_samples, n_features = 10000, 5
X = np.random.rand(n_samples, n_features).astype(np.float32) * 10.0
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Test different batch sizes
batch_sizes = [100, 500, 1000, 2500, 5000, 10000]
k = 20
# Warm up numba
warmup_queries = np.random.rand(50, n_features).astype(np.float32) * 10.0
_ = parallel_tree_query(numba_tree, warmup_queries, k=k, output_rdist=False)
sklearn_speedups = []
numba_speedups = []
for batch_size in batch_sizes:
if batch_size > n_samples:
continue
# Generate query batch
query_data = (
np.random.rand(batch_size, n_features).astype(np.float32) * 10.0
)
# Time both implementations
sklearn_result, sklearn_time = time_function(
sklearn_tree.query, query_data, k=k
)
numba_result, numba_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
sklearn_qps = batch_size / sklearn_time
numba_qps = batch_size / numba_time
speedup = sklearn_time / numba_time
test_name = f"batch_scaling_{batch_size}"
perf_metrics.record_metric(test_name, "sklearn_qps", sklearn_qps)
perf_metrics.record_metric(test_name, "numba_qps", numba_qps)
perf_metrics.record_metric(test_name, "speedup", speedup)
print(
f"\nBatch size {batch_size:5d}: Sklearn {sklearn_qps:8.0f} q/s, "
f"Numba {numba_qps:8.0f} q/s, Speedup: {speedup:.2f}x"
)
# Verify correctness
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == numba_dists.shape
# Performance should be reasonable for larger batches
# Small batches may be slower due to parallelization overhead
if batch_size >= 3000: # Adjusted threshold based on empirical results
assert (
numba_time < sklearn_time * 1.5
), f"Numba too slow for large batch {batch_size}: {speedup:.2f}x"
# Expect advantage for large batches
assert (
speedup > 0.8
), f"Expected numba advantage for large batch {batch_size}: {speedup:.2f}x"
elif batch_size >= 1000:
# Medium batches should be competitive
assert (
numba_time < sklearn_time * 2.0
), f"Numba too slow for medium batch {batch_size}: {speedup:.2f}x"
print(f"\nBatch Scaling Analysis:")
print(
f" Numba shows increasing advantage with larger batches due to parallelization benefits"
)
print(
f" Small batches (<1000) have overhead, large batches (>2000) show significant speedup"
)
print(f" This demonstrates numba's optimization for large query workloads")
def test_kdtree_query_performance_ultra_large_batch(self, perf_metrics):
"""Test numba performance on ultra-large query batches (its optimal use case)."""
np.random.seed(42)
# Use a reasonably sized dataset for the tree
n_samples, n_features = 15000, 6
X = np.random.rand(n_samples, n_features).astype(np.float32) * 10.0
# Build trees
sklearn_tree = SklearnKDTree(X, leaf_size=40)
numba_tree = build_kdtree(X, leaf_size=40)
# Test with ultra-large query batch - this is numba's sweet spot
np.random.seed(123)
n_queries = 25000 # Very large query batch
query_data = np.random.rand(n_queries, n_features).astype(np.float32) * 10.0
k = 25
# Warm up numba
_ = parallel_tree_query(numba_tree, query_data[:20], k=k, output_rdist=False)
# Time both implementations
sklearn_result, sklearn_time = time_function(
sklearn_tree.query, query_data, k=k
)
numba_result, numba_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
# Calculate metrics
sklearn_qps = n_queries / sklearn_time
numba_qps = n_queries / numba_time
speedup = sklearn_time / numba_time
# Record metrics
test_name = f"ultra_large_batch_{n_queries}_queries"
perf_metrics.record_metric(test_name, "sklearn_time", sklearn_time)
perf_metrics.record_metric(test_name, "numba_time", numba_time)
perf_metrics.record_metric(test_name, "speedup", speedup)
perf_metrics.record_metric(test_name, "sklearn_qps", sklearn_qps)
perf_metrics.record_metric(test_name, "numba_qps", numba_qps)
print(f"\nUltra-Large Batch Performance ({n_queries} queries, k={k}):")
print(f" Dataset: {n_samples} samples x {n_features} features")
print(f" Sklearn: {sklearn_time:.4f}s ({sklearn_qps:,.0f} queries/sec)")
print(f" Numba: {numba_time:.4f}s ({numba_qps:,.0f} queries/sec)")
print(f" Speedup: {speedup:.2f}x")
print(
f" Efficiency gain: {(numba_qps - sklearn_qps):,.0f} additional queries/sec"
)
# Verify correctness
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == (n_queries, k)
assert numba_dists.shape == (n_queries, k)
assert np.all(np.isfinite(numba_dists))
assert np.all(numba_inds >= 0)
assert np.all(numba_inds < n_samples)
# Performance expectations for ultra-large batches
# This is numba's optimal use case - should show significant speedup
assert (
numba_time < sklearn_time
), f"Numba should be faster for ultra-large batches: {speedup:.2f}x"
# Expect substantial speedup on ultra-large batches
assert (
speedup > 1.0
), f"Expected major numba advantage for ultra-large batches: {speedup:.2f}x (target: >1.0x)"
# Throughput should be significantly higher
assert (
numba_qps > sklearn_qps * 1.0
), f"Expected 1.0x+ throughput improvement: {numba_qps/sklearn_qps:.2f}x"
@pytest.mark.performance
class TestKDTreeRegressionBaseline:
"""Baseline performance tests for regression detection."""
def test_kdtree_baseline_performance(self):
"""
Baseline performance test for KDTree operations.
Establishes performance baselines that can be used to detect regressions.
"""
np.random.seed(42)
# Standard test dataset
n_samples, n_features = 10000, 5
X = np.random.rand(n_samples, n_features).astype(np.float32) * 10.0
# Warm up numba compilation
warmup_tree = build_kdtree(X[:100], leaf_size=40)
warmup_queries = X[:10]
_ = parallel_tree_query(warmup_tree, warmup_queries, k=10, output_rdist=False)
print(f"\nKDTree Baseline Performance Report:")
print(f" Dataset: {n_samples} samples x {n_features} features")
print(f" Hardware: {platform.platform()}")
if HAS_PSUTIL:
print(f" CPU cores: {psutil.cpu_count(logical=True)}")
print(f" Memory: {psutil.virtual_memory().total / (1024**3):.1f} GB")
else:
import os
print(f" CPU cores: {os.cpu_count() or 'unknown'}")
# Test construction performance
sklearn_tree, sklearn_construction_time = time_function(
SklearnKDTree, X, leaf_size=40
)
numba_tree, numba_construction_time = time_function(
build_kdtree, X, leaf_size=40
)
# Test query performance with large batch (target use case)
n_queries = 5000 # Large query batch to showcase parallel advantages
query_data = np.random.rand(n_queries, n_features).astype(np.float32) * 10.0
k = 30 # Reasonable k value
sklearn_result, sklearn_query_time = time_function(
sklearn_tree.query, query_data, k=k
)
numba_result, numba_query_time = time_function(
parallel_tree_query, numba_tree, query_data, k=k, output_rdist=False
)
# Calculate metrics
construction_speedup = sklearn_construction_time / numba_construction_time
query_speedup = sklearn_query_time / numba_query_time
print(f"\nConstruction Performance:")
print(f" Sklearn: {sklearn_construction_time:.4f} seconds")
print(f" Numba: {numba_construction_time:.4f} seconds")
print(f" Speedup: {construction_speedup:.2f}x")
print(f"\nQuery Performance ({n_queries} queries, k={k}):")
print(f" Sklearn: {sklearn_query_time:.4f} seconds")
print(f" Numba: {numba_query_time:.4f} seconds")
print(f" Speedup: {query_speedup:.2f}x")
print(f"\nThroughput:")
print(f" Construction: {n_samples/numba_construction_time:.0f} samples/sec")
print(f" Queries: {n_queries/numba_query_time:.0f} queries/sec")
# Basic performance requirements
assert (
numba_construction_time < 2.0
), f"Construction too slow: {numba_construction_time:.4f}s"
assert numba_query_time < 1.0, f"Queries too slow: {numba_query_time:.4f}s"
# Verify results are correct
sklearn_dists, sklearn_inds = sklearn_result
numba_dists, numba_inds = numba_result
assert sklearn_dists.shape == numba_dists.shape
assert sklearn_inds.shape == numba_inds.shape
assert np.all(np.isfinite(numba_dists))
assert np.all(numba_inds >= 0)
assert np.all(numba_inds < n_samples)
# Expected performance characteristics
# After warmup, numba should be competitive or better
print(f"\nPerformance Analysis:")
if construction_speedup > 1.0:
print(f" ✅ Construction {construction_speedup:.2f}x faster than sklearn")
else:
print(
f" ⚠️ Construction {1/construction_speedup:.2f}x slower than sklearn"
)
if query_speedup > 1.0:
print(f" ✅ Queries {query_speedup:.2f}x faster than sklearn")
else:
print(f" ⚠️ Queries {1/query_speedup:.2f}x slower than sklearn")
return_info = {
"construction_speedup": construction_speedup,
"query_speedup": query_speedup,
"numba_construction_time": numba_construction_time,
"numba_query_time": numba_query_time,
}
# Note: return_info could be used for CI comparison but we don't return it
# to avoid pytest warnings
================================================
FILE: evoc/uint8_nndescent.py
================================================
import numba
import numpy as np
from numba import types
from numba.core import cgutils
from numba.extending import intrinsic
import llvmlite.ir as ir
from .common_nndescent import (
tau_rand_int,
make_heap,
deheap_sort,
flagged_heap_push,
build_candidates,
apply_graph_update_array,
apply_sorted_graph_updates,
)
from .nested_parallelism import ENABLE_NESTED_PARALLELISM
# Used for a floating point "nearly zero" comparison
EPS = 1e-8
INT32_MIN = np.iinfo(np.int32).min + 1
INT32_MAX = np.iinfo(np.int32).max - 1
INF = np.float32(np.inf)
point_indices_type = numba.int32[::1]
@intrinsic
def popcnt_u8(typingctx, val):
"""Hardware popcount for uint8 using LLVM intrinsic."""
sig = types.uint8(types.uint8)
def popcnt_u8_impl(context, builder, sig, args):
[val] = args
# Declare LLVM's ctpop intrinsic for i8
llvm_i8 = val.type
fnty = ir.FunctionType(llvm_i8, [llvm_i8])
llvm_ctpop = cgutils.get_or_insert_function(
builder.module, fnty, "llvm.ctpop.i8"
)
result = builder.call(llvm_ctpop, [val])
return result
return sig, popcnt_u8_impl
@intrinsic
def popcnt_u64(typingctx, val):
"""Hardware popcount for uint64 using LLVM intrinsic."""
sig = types.uint64(types.uint64)
def popcnt_u64_impl(context, builder, sig, args):
[val] = args
llvm_i64 = val.type
fnty = ir.FunctionType(llvm_i64, [llvm_i64])
llvm_ctpop = cgutils.get_or_insert_function(
builder.module, fnty, "llvm.ctpop.i64"
)
result = builder.call(llvm_ctpop, [val])
return result
return sig, popcnt_u64_impl
@numba.njit(
[
"f4(u1[::1],u1[::1])",
numba.types.float32(
numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
),
],
fastmath=True,
cache=True,
nogil=True,
)
def fast_bit_jaccard(x, y):
"""Binary Jaccard using hardware POPCNT instruction."""
result = np.uint32(0)
denom = np.uint32(0)
dim = x.shape[0]
for i in range(dim):
and_val = x[i] & y[i]
or_val = x[i] | y[i]
result += popcnt_u8(and_val)
denom += popcnt_u8(or_val)
if denom > 0:
return -(np.float32(result) / np.float32(denom))
else:
return 0.0
@intrinsic
def load_u64_from_u8_array(typingctx, arr, offset):
"""Load a uint64 from a uint8 array at given byte offset."""
sig = types.uint64(types.Array(types.uint8, 1, "C"), types.intp)
def load_u64_impl(context, builder, sig, args):
[arr, offset] = args
# Get the array structure
ary = context.make_array(sig.args[0])(context, builder, arr)
ptr = ary.data
# Get element pointer at offset
elem_ptr = builder.gep(ptr, [offset])
# Cast uint8* to uint64*
i64_ptr_type = ir.PointerType(ir.IntType(64))
ptr_u64 = builder.bitcast(elem_ptr, i64_ptr_type)
# Load uint64
value = builder.load(ptr_u64)
return value
return sig, load_u64_impl
@numba.njit(
[
"f4(u1[::1],u1[::1])",
numba.types.float32(
numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
),
],
fastmath=True,
cache=True,
boundscheck=False,
nogil=True,
)
def fast_bit_jaccard_u64(x, y):
"""
Use load intrinsic to avoid type conversion overhead.
REQUIRES: Array size divisible by 8.
"""
result = np.uint64(0)
denom = np.uint64(0)
n_u64 = x.shape[0] // 8
for i in range(n_u64):
offset = i * 8
# Load uint64 values directly
x_val = load_u64_from_u8_array(x, offset)
y_val = load_u64_from_u8_array(y, offset)
and_val = x_val & y_val
or_val = x_val | y_val
result += popcnt_u64(and_val)
denom += popcnt_u64(or_val)
if denom > 0:
return -(np.float32(result) / np.float32(denom))
else:
return 0.0
@numba.njit(
numba.types.Tuple((numba.int32[::1], numba.int32[::1]))(
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int32[::1],
numba.int64[::1],
),
locals={
"n_left": numba.uint32,
"n_right": numba.uint32,
"left_data": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
"right_data": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
"test_data": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
"hyperplane_vector": numba.uint8[::1],
"hyperplane_offset": numba.float32,
"margin": numba.float32,
"d": numba.uint32,
"i": numba.uint32,
"left_index": numba.uint32,
"right_index": numba.uint32,
},
fastmath=True,
nogil=True,
cache=True,
)
def uint8_random_projection_split(data, indices, rng_state):
"""Given a set of ``graph_indices`` for graph_data points from ``graph_data``, create
a random hyperplane to split the graph_data, returning two arrays graph_indices
that fall on either side of the hyperplane. This is the basis for a
random projection tree, which simply uses this splitting recursively.
This particular split uses cosine distance to determine the hyperplane
and which side each graph_data sample falls on.
Parameters
----------
data: array of shape (n_samples, n_features)
The original graph_data to be split
indices: array of shape (tree_node_size,)
The graph_indices of the elements in the ``graph_data`` array that are to
be split in the current operation.
rng_state: array of int64, shape (3,)
The internal state of the rng
Returns
-------
indices_left: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
indices_right: array
The elements of ``graph_indices`` that fall on the "left" side of the
random hyperplane.
"""
dim = data.shape[1]
# Select two random points, set the hyperplane between them
left_index = tau_rand_int(rng_state) % indices.shape[0]
right_index = tau_rand_int(rng_state) % indices.shape[0]
right_index += left_index == right_index
right_index = right_index % indices.shape[0]
left = indices[left_index]
right = indices[right_index]
left_data = data[left]
right_data = data[right]
# Compute the normal vector to the hyperplane (the vector between
# the two points)
hyperplane_vector = np.empty(dim * 2, dtype=np.uint8)
positive_hyperplane_component = hyperplane_vector[:dim]
negative_hyperplane_component = hyperplane_vector[dim:]
for d in range(dim):
xor_vector = left_data[d] ^ right_data[d]
positive_hyperplane_component[d] = xor_vector & left_data[d]
negative_hyperplane_component[d] = xor_vector & right_data[d]
hyperplane_norm = 0.0
left_norm = 0.0
right_norm = 0.0
for d in range(dim):
hyperplane_norm += popcnt_u8(hyperplane_vector[d])
left_norm += popcnt_u8(left_data[d])
right_norm += popcnt_u8(right_data[d])
# For each point compute the margin (project into normal vector)
# If we are on lower side of the hyperplane put in one pile, otherwise
# put it in the other pile (if we hit hyperplane on the nose, flip a coin)
n_left = 0
n_right = 0
side = np.empty(indices.shape[0], np.bool_)
for i in range(indices.shape[0]):
margin = 0.0
local_rng_state = rng_state + np.int64(i)
test_data = data[indices[i]]
for d in range(dim):
margin += popcnt_u8(positive_hyperplane_component[d] & test_data[d])
margin -= popcnt_u8(negative_hyperplane_component[d] & test_data[d])
if abs(margin) < EPS:
side[i] = np.bool_(tau_rand_int(local_rng_state) % 2)
if side[i] == 0:
n_left += 1
else:
n_right += 1
elif margin > 0:
side[i] = 0
n_left += 1
else:
side[i] = 1
n_right += 1
# If all points end up on one side, something went wrong numerically
# In this case, assign points randomly; they are likely very close anyway
if n_left == 0 or n_right == 0:
n_left = 0
n_right = 0
for i in range(indices.shape[0]):
side[i] = tau_rand_int(rng_state) % 2
if side[i] == 0:
n_left += 1
else:
n_right += 1
# Now that we have the counts allocate arrays
indices_left = np.empty(n_left, dtype=np.int32)
indices_right = np.empty(n_right, dtype=np.int32)
# Populate the arrays with graph_indices according to which side they fell on
n_left = 0
n_right = 0
for i in range(side.shape[0]):
if side[i] == 0:
indices_left[n_left] = indices[i]
n_left += 1
else:
indices_right[n_right] = indices[i]
n_right += 1
return indices_left, indices_right
@numba.njit(
numba.void(
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int32[::1],
numba.types.ListType(numba.int32[::1]),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
cache=True,
)
def make_uint8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size=30,
max_depth=200,
):
if indices.shape[0] > leaf_size and max_depth > 0:
(
left_indices,
right_indices,
) = uint8_random_projection_split(data, indices, rng_state)
make_uint8_tree(
data,
left_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
make_uint8_tree(
data,
right_indices,
point_indices,
rng_state,
leaf_size,
max_depth - 1,
)
else:
point_indices.append(indices)
return
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={"n_leaves": numba.int64, "i": numba.int64},
parallel=True,
cache=True,
)
def make_uint8_leaf_array_parallel(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(point_indices_type)
make_uint8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = leaf_size
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in numba.prange(n_leaves):
points = point_indices[numba.int64(i)]
leaf_size = numba.int32(len(points))
result[i, :leaf_size] = points
return result
@numba.njit(
numba.int32[:, ::1](
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64[::1],
numba.int64,
numba.int64,
),
nogil=True,
locals={"n_leaves": numba.int64, "i": numba.int64},
parallel=False,
cache=True,
)
def make_uint8_leaf_array_serial(data, rng_state, leaf_size=30, max_depth=200):
indices = np.arange(data.shape[0]).astype(np.int32)
point_indices = numba.typed.List.empty_list(point_indices_type)
make_uint8_tree(
data,
indices,
point_indices,
rng_state,
leaf_size,
max_depth=max_depth,
)
n_leaves = numba.int64(len(point_indices))
max_leaf_size = leaf_size
for i in range(n_leaves):
points = point_indices[numba.int64(i)]
max_leaf_size = max(max_leaf_size, numba.int32(len(points)))
result = np.full((n_leaves, max_leaf_size), -1, dtype=np.int32)
for i in range(n_leaves):
points = point_indices[numba.int64(i)]
leaf_size = numba.int32(len(points))
result[i, :leaf_size] = points
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.int64,
numba.int64,
),
parallel=True,
cache=True,
)
def make_uint8_forest_no_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_uint8_leaf_array_serial(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
@numba.njit(
numba.types.List(numba.int32[:, ::1])(
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64[:, ::1],
numba.int64,
numba.int64,
),
parallel=True,
cache=True,
)
def make_uint8_forest_with_nested_parallelism(data, rng_states, leaf_size, max_depth):
result = [np.empty((1, 1), dtype=np.int32)] * rng_states.shape[0]
for i in numba.prange(len(result)):
result[i] = make_uint8_leaf_array_parallel(
data, rng_states[i], leaf_size, max_depth=max_depth
)
return result
def make_uint8_forest(data, rng_states, leaf_size=30, max_depth=200):
if ENABLE_NESTED_PARALLELISM:
return make_uint8_forest_with_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
else:
return make_uint8_forest_no_nested_parallelism(
data, rng_states, leaf_size, max_depth
)
@numba.njit(
numba.float32[:, :, ::1](
numba.float32[:, :, ::1],
numba.int32[::1],
numba.types.Array(numba.types.int32, 2, "C", readonly=True),
numba.float32[:],
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64,
),
parallel=True,
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"t": numba.uint16,
"r": numba.uint32,
"n": numba.uint32,
"idx": numba.uint32,
"data_p": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
},
cache=True,
)
def generate_leaf_updates_uint8(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
):
block_size = leaf_block.shape[0]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
for r in range(rows_per_thread):
n = t * rows_per_thread + r
if n >= block_size:
break
for i in range(leaf_block.shape[1]):
p = leaf_block[n, i]
if p < 0:
break
data_p = data[p]
for j in range(i, leaf_block.shape[1]):
q = leaf_block[n, j]
if q < 0:
break
d = fast_bit_jaccard(data_p, data[q])
if d < dist_thresholds[p] or d < dist_thresholds[q]:
updates[t, idx, 0] = p
updates[t, idx, 1] = q
updates[t, idx, 2] = d
idx += 1
n_updates_per_thread[t] = idx
return updates
@numba.njit(
[
numba.void(
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.types.optional(
numba.types.Array(numba.types.int32, 2, "C", readonly=True)
),
numba.types.int32,
),
],
locals={
"d": numba.float32,
"p": numba.int32,
"q": numba.int32,
"i": numba.uint16,
"updates": numba.float32[:, :, ::1],
"n_updates_per_thread": numba.int32[::1],
},
parallel=True,
cache=True,
)
def init_rp_tree_uint8(data, current_graph, leaf_array, n_threads):
n_leaves = leaf_array.shape[0]
block_size = 64 * n_threads
n_blocks = n_leaves // block_size
max_leaf_size = leaf_array.shape[1]
updates_per_thread = (
int(block_size * max_leaf_size * (max_leaf_size - 1) / (2 * n_threads)) + 1
)
updates = np.zeros((n_threads, updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_leaves, (i + 1) * block_size)
leaf_block = leaf_array[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
updates = generate_leaf_updates_uint8(
updates, n_updates_per_thread, leaf_block, dist_thresholds, data, n_threads
)
n_vertices = current_graph[0].shape[0]
vertex_block_size = n_vertices // n_threads + 1
for t in numba.prange(n_threads):
block_start = t * vertex_block_size
block_end = min(block_start + vertex_block_size, n_vertices)
for j in range(n_threads):
for k in range(n_updates_per_thread[j]):
p = np.int32(updates[j, k, 0])
q = np.int32(updates[j, k, 1])
d = np.float32(updates[j, k, 2])
if p == -1 or q == -1:
continue
if p >= block_start and p < block_end:
flagged_heap_push(
current_graph[1][p],
current_graph[0][p],
current_graph[2][p],
d,
q,
)
if q >= block_start and q < block_end:
flagged_heap_push(
current_graph[1][q],
current_graph[0][q],
current_graph[2][q],
d,
p,
)
@numba.njit(
numba.types.void(
numba.int32,
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.types.Tuple(
(numba.int32[:, ::1], numba.float32[:, ::1], numba.uint8[:, ::1])
),
numba.int64[::1],
),
fastmath=True,
locals={"d": numba.float32, "idx": numba.int32, "i": numba.int32},
cache=True,
)
def init_random_uint8(n_neighbors, data, heap, rng_state):
for i in range(data.shape[0]):
if heap[0][i, 0] < 0.0:
for j in range(n_neighbors - np.sum(heap[0][i] >= 0.0)):
idx = np.abs(tau_rand_int(rng_state)) % data.shape[0]
if idx in heap[0][i]:
continue
d = fast_bit_jaccard(data[idx], data[i])
flagged_heap_push(heap[1][i], heap[0][i], heap[2][i], d, idx)
return
@numba.njit(
numba.types.void(
numba.float32[:, :, ::1],
numba.int32[::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
},
parallel=True,
cache=True,
)
def generate_graph_update_array_uint8(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
block_size = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size // n_threads) + 1
for t in numba.prange(n_threads):
idx = 0
updates_are_full = False
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size:
break
for j in range(max_new_candidates):
p = int(new_candidate_block[i, j])
if p < 0:
continue
data_p = data[p]
for k in range(j, max_new_candidates):
q = int(new_candidate_block[i, k])
if q < 0:
continue
d = fast_bit_jaccard(data_p, data[q])
if d <= dist_thresholds[p] or d <= dist_thresholds[q]:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
if idx >= update_array.shape[1]:
updates_are_full = True
break
if updates_are_full:
break
for k in range(max_old_candidates):
q = int(old_candidate_block[i, k])
if q < 0:
continue
d = fast_bit_jaccard(data_p, data[q])
if d <= dist_thresholds[p] or d <= dist_thresholds[q]:
update_array[t, idx, 0] = p
update_array[t, idx, 1] = q
update_array[t, idx, 2] = d
idx += 1
if idx >= update_array.shape[1]:
updates_are_full = True
break
if updates_are_full:
break
if updates_are_full:
break
n_updates_per_thread[t] = idx
@numba.njit(
numba.void(
numba.float32[:, :, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.int32[:, ::1],
numba.float32[:],
numba.types.Array(numba.types.uint8, 2, "C", readonly=True),
numba.int64,
),
locals={
"data_p": numba.types.Array(numba.types.uint8, 1, "C", readonly=True),
"dist_thresh_p": numba.float32,
"dist_thresh_q": numba.float32,
"p": numba.int32,
"q": numba.int32,
"d": numba.float32,
"max_updates": numba.intp,
"max_threshold": numba.float32,
"p_block": numba.int32,
"q_block": numba.int32,
},
parallel=True,
cache=True,
boundscheck=False,
)
def generate_sorted_graph_update_array_uint8(
update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
):
"""
Generate graph updates pre-sorted by target block for uint8 data.
"""
block_size_candidates = new_candidate_block.shape[0]
max_new_candidates = new_candidate_block.shape[1]
max_old_candidates = old_candidate_block.shape[1]
rows_per_thread = (block_size_candidates // n_threads) + 1
n_vertices = data.shape[0]
vertex_block_size = n_vertices // n_threads + 1
max_updates = update_array.shape[1]
max_updates_per_src_thread = max_updates // n_threads
# Reset update counts
for b in numba.prange(n_threads):
for t in range(n_threads + 1):
n_updates_per_block[b, t] = 0
# Each thread generates updates and places them in appropriate buckets
for t in numba.prange(n_threads):
# Thread-local counters for each bucket
local_counts = np.zeros(n_threads, dtype=np.int32)
for r in range(rows_per_thread):
i = t * rows_per_thread + r
if i >= block_size_candidates:
break
for j in range(max_new_candidates):
p = new_candidate_block[i, j]
if p < 0:
continue
data_p = data[p]
dist_thresh_p = dist_thresholds[p]
p_block = p // vertex_block_size
if p_block >= n_threads:
p_block = n_threads - 1
# Compare with other new candidates
for k in range(j, max_new_candidates):
q = new_candidate_block[i, k]
if q < 0:
continue
d = fast_bit_jaccard(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Compare with old candidates
for k in range(max_old_candidates):
q = old_candidate_block[i, k]
if q < 0:
continue
d = fast_bit_jaccard(data_p, data[q])
dist_thresh_q = dist_thresholds[q]
max_threshold = max(dist_thresh_p, dist_thresh_q)
if d <= max_threshold:
q_block = q // vertex_block_size
if q_block >= n_threads:
q_block = n_threads - 1
# Place update in p's bucket
bucket_idx = local_counts[p_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[p_block, write_idx, 0] = p
update_array[p_block, write_idx, 1] = q
update_array[p_block, write_idx, 2] = d
local_counts[p_block] += 1
# If q is in a different block, also place in q's bucket
if q_block != p_block:
bucket_idx = local_counts[q_block]
write_idx = t * max_updates_per_src_thread + bucket_idx
if write_idx < max_updates:
update_array[q_block, write_idx, 0] = p
update_array[q_block, write_idx, 1] = q
update_array[q_block, write_idx, 2] = d
local_counts[q_block] += 1
# Record total updates generated by this thread for each bucket
for b in range(n_threads):
n_updates_per_block[b, t + 1] = local_counts[b]
def nn_descent_uint8(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using uint8 data.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_uint8(data, current_graph, leaf_array, n_threads)
init_random_uint8(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
n_threads = numba.get_num_threads()
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
update_array = np.empty((n_threads, max_updates_per_thread, 3), dtype=np.float32)
n_updates_per_thread = np.zeros(n_threads, dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
n_vertices = new_candidate_neighbors.shape[0]
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
generate_graph_update_array_uint8(
update_array,
n_updates_per_thread,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_graph_update_array(
current_graph, update_array, n_updates_per_thread, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
def nn_descent_uint8_sorted(
data,
n_neighbors,
rng_state,
max_candidates=50,
n_iters=10,
delta=0.001,
delta_improv=None,
leaf_array=None,
verbose=False,
):
"""
Perform approximate nearest neighbor descent algorithm using uint8 data.
This version uses pre-sorted updates bucketed by target block for potentially
better performance when n_threads is large.
Parameters:
- data: The input data array.
- n_neighbors: The number of nearest neighbors to search for.
- rng_state: The random number generator state.
- max_candidates: The maximum number of candidates to consider during the search. Default is 50.
- n_iters: The number of iterations to perform. Default is 10.
- delta: The stopping threshold based on update count. Default is 0.001.
- delta_improv: Optional stopping threshold based on relative improvement in total
graph distance. When set (e.g., 0.001 for 0.1%), the algorithm will also
terminate when the relative improvement in sum of all distances drops below
this threshold. This can provide earlier termination on data with good
structure, adapting to the intrinsic difficulty of the dataset. Default is None
(disabled).
- leaf_array: The array representing the leaf structure of the RP-tree. Default is None.
- verbose: Whether to print progress information. Default is False.
Returns:
- The sorted nearest neighbor graph.
"""
n_threads = numba.get_num_threads()
current_graph = make_heap(data.shape[0], n_neighbors)
init_rp_tree_uint8(data, current_graph, leaf_array, n_threads)
init_random_uint8(n_neighbors, data, current_graph, rng_state)
n_vertices = data.shape[0]
block_size = 65536 // n_threads
n_blocks = n_vertices // block_size
max_updates_per_thread = int(
((max_candidates**2 + max_candidates * (max_candidates - 1) / 2) * block_size)
)
update_array = np.empty((n_threads, max_updates_per_thread, 3), dtype=np.float32)
n_updates_per_block = np.zeros((n_threads, n_threads + 1), dtype=np.int32)
# For distance-based termination
prev_sum_dist = None
for n in range(n_iters):
if verbose:
print("\t", n + 1, " / ", n_iters)
(new_candidate_neighbors, old_candidate_neighbors) = build_candidates(
current_graph, max_candidates, rng_state, n_threads
)
c = 0
for i in range(n_blocks + 1):
block_start = i * block_size
block_end = min(n_vertices, (i + 1) * block_size)
new_candidate_block = new_candidate_neighbors[block_start:block_end]
old_candidate_block = old_candidate_neighbors[block_start:block_end]
dist_thresholds = current_graph[1][:, 0]
generate_sorted_graph_update_array_uint8(
update_array,
n_updates_per_block,
new_candidate_block,
old_candidate_block,
dist_thresholds,
data,
n_threads,
)
c += apply_sorted_graph_updates(
current_graph, update_array, n_updates_per_block, n_threads
)
# Check update count termination
if c <= delta * n_neighbors * data.shape[0]:
if verbose:
print("\tStopping threshold met -- exiting after", n + 1, "iterations")
return deheap_sort(current_graph[0], current_graph[1])
# Check distance improvement termination (if enabled)
if delta_improv is not None:
all_distances = current_graph[1]
valid_mask = all_distances < INF
sum_dist = np.sum(all_distances[valid_mask])
if prev_sum_dist is not None:
rel_improv = abs(sum_dist - prev_sum_dist) / abs(prev_sum_dist)
if rel_improv < delta_improv:
if verbose:
print(
f"\tDistance improvement threshold met ({rel_improv:.4%} < {delta_improv:.4%})"
f" -- exiting after {n + 1} iterations"
)
return deheap_sort(current_graph[0], current_graph[1])
prev_sum_dist = sum_dist
block_size = min(n_vertices, 2 * block_size)
n_blocks = n_vertices // block_size
return deheap_sort(current_graph[0], current_graph[1])
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["setuptools>=61.2"]
build-backend = "setuptools.build_meta"
[project]
name = "evoc"
version = "0.3.1"
authors = [{name = "Leland McInnes", email = "leland.mcinnes@gmail.com"}]
maintainers = [{name = "Leland McInnes", email = "leland.mcinnes@gmail.com"}]
description = "Embedding Vector Oriented Clustering"
readme = "README.rst"
keywords = ["embedding vector", "vector database", "topic modelling", "cluster", "clustering"]
license = "BSD-2-Clause"
classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Development Status :: 4 - Beta",
"Operating System :: OS Independent",
]
urls = {Homepage = "https://github.com/TutteInstitute/evoc"}
requires-python = ">=3.10"
dependencies = [
"numpy>=1.21",
"scikit-learn>=1.1",
"numba>=0.59",
"tqdm",
]
[dependency-groups]
cicd = ["pytest", "pytest-azurepipelines", "pytest-cov", "matplotlib"]
[tool.setuptools]
zip-safe = false
packages = ["evoc"]
include-package-data = false
================================================
FILE: pytest.ini
================================================
[pytest]
markers =
performance: marks tests as performance tests (deselect with '-m "not performance"')
slow: marks tests as slow running tests
integration: marks tests as integration tests
testpaths = evoc/tests
addopts = -v --tb=short
timeout = 300
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::FutureWarning
================================================
FILE: scripts/run_performance_tests.py
================================================
#!/usr/bin/env python3
"""
Performance benchmark runner for evoc knn_graph module.
This script runs performance tests and generates a report that can be used
for performance regression monitoring in CI/CD pipelines.
"""
import argparse
import json
import sys
import time
import platform
import subprocess
from pathlib import Path
def run_performance_tests(output_file=None, verbose=False):
"""Run performance tests and collect results."""
print("Running EVoC knn_graph performance benchmarks...")
print(f"Platform: {platform.platform()}")
print(f"Python: {platform.python_version()}")
print("-" * 60)
# Run pytest with performance markers
cmd = [
sys.executable, "-m", "pytest",
"evoc/tests/test_knn_graph_performance.py",
"-m", "performance",
"-v"
]
if verbose:
cmd.append("-s")
# Add JSON report plugin if available
try:
import pytest_json_report
if output_file:
cmd.extend(["--json-report", f"--json-report-file={output_file}"])
except ImportError:
print("Note: pytest-json-report not installed, basic output only")
start_time = time.time()
result = subprocess.run(cmd, capture_output=not verbose, text=True)
duration = time.time() - start_time
if result.returncode == 0:
print(f"\nAll performance tests passed in {duration:.1f} seconds!")
else:
print(f"\nSome performance tests failed or had issues.")
if not verbose and result.stdout:
print("STDOUT:")
print(result.stdout)
if result.stderr:
print("STDERR:")
print(result.stderr)
return result.returncode == 0
def generate_performance_report(test_results_file, output_file):
"""Generate a human-readable performance report."""
try:
with open(test_results_file, 'r') as f:
data = json.load(f)
except FileNotFoundError:
print(f"Test results file {test_results_file} not found")
return False
except json.JSONDecodeError:
print(f"Could not parse JSON from {test_results_file}")
return False
# Extract performance metrics
report = {
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'platform': platform.platform(),
'python_version': platform.python_version(),
'total_tests': data.get('summary', {}).get('total', 0),
'passed_tests': data.get('summary', {}).get('passed', 0),
'failed_tests': data.get('summary', {}).get('failed', 0),
'duration': data.get('duration', 0),
'tests': []
}
# Process individual test results
for test in data.get('tests', []):
if 'performance' in test.get('keywords', []):
test_info = {
'name': test.get('nodeid', '').split('::')[-1],
'duration': test.get('duration', 0),
'outcome': test.get('outcome', 'unknown'),
'stdout': test.get('call', {}).get('stdout', '')
}
report['tests'].append(test_info)
# Write report
with open(output_file, 'w') as f:
json.dump(report, f, indent=2)
print(f"Performance report written to {output_file}")
return True
def check_performance_regression(current_file, baseline_file, threshold=1.5):
"""
Check for performance regressions by comparing current results to baseline.
Args:
current_file: JSON file with current test results
baseline_file: JSON file with baseline results
threshold: Maximum allowed slowdown ratio (e.g., 1.5 = 50% slower)
Returns:
bool: True if no significant regressions detected
"""
try:
with open(current_file, 'r') as f:
current = json.load(f)
with open(baseline_file, 'r') as f:
baseline = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error reading performance data: {e}")
return False
print(f"Comparing performance to baseline from {baseline.get('timestamp', 'unknown')}")
regressions = []
improvements = []
# Compare test durations
current_tests = {t['name']: t for t in current.get('tests', [])}
baseline_tests = {t['name']: t for t in baseline.get('tests', [])}
for test_name in current_tests:
if test_name in baseline_tests:
current_duration = current_tests[test_name]['duration']
baseline_duration = baseline_tests[test_name]['duration']
if baseline_duration > 0:
ratio = current_duration / baseline_duration
if ratio > threshold:
regressions.append({
'test': test_name,
'current': current_duration,
'baseline': baseline_duration,
'ratio': ratio
})
elif ratio < 0.8: # 20% improvement
improvements.append({
'test': test_name,
'current': current_duration,
'baseline': baseline_duration,
'ratio': ratio
})
# Report results
if regressions:
print(f"\n⚠️ Performance regressions detected:")
for reg in regressions:
print(f" {reg['test']}: {reg['ratio']:.2f}x slower "
f"({reg['current']:.3f}s vs {reg['baseline']:.3f}s)")
if improvements:
print(f"\n✅ Performance improvements:")
for imp in improvements:
print(f" {imp['test']}: {imp['ratio']:.2f}x faster "
f"({imp['current']:.3f}s vs {imp['baseline']:.3f}s)")
if not regressions and not improvements:
print("\n✅ No significant performance changes detected")
return len(regressions) == 0
def main():
parser = argparse.ArgumentParser(description="Run EVoC performance benchmarks")
parser.add_argument("--output", "-o", help="Output file for test results (JSON)")
parser.add_argument("--report", "-r", help="Generate human-readable report file")
parser.add_argument("--baseline", "-b", help="Compare against baseline performance file")
parser.add_argument("--threshold", "-t", type=float, default=1.5,
help="Regression threshold (default: 1.5x slower)")
parser.add_argument("--verbose", "-v", action="store_true",
help="Verbose output")
args = parser.parse_args()
# Default output file
if not args.output:
timestamp = time.strftime("%Y%m%d_%H%M%S")
args.output = f"performance_results_{timestamp}.json"
# Run performance tests
success = run_performance_tests(args.output, args.verbose)
if not success:
print("Performance tests failed")
return 1
# Generate report if requested
if args.report:
generate_performance_report(args.output, args.report)
# Check for regressions if baseline provided
if args.baseline:
if not check_performance_regression(args.output, args.baseline, args.threshold):
print("Performance regression detected!")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
================================================
FILE: setup.py
================================================
from setuptools import setup
if __name__ == '__main__':
setup()