├── .github └── workflows │ └── test-code.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── images ├── illustration_circles.gif └── performance.png ├── incdbscan ├── __init__.py ├── _bfscomponentfinder.py ├── _deleter.py ├── _inserter.py ├── _labels.py ├── _neighbor_searcher.py ├── _object.py ├── _objects.py ├── _utils.py ├── incrementaldbscan.py └── tests │ ├── conftest.py │ ├── test_deleter.py │ ├── test_incrementaldbscan.py │ ├── test_inserter.py │ ├── test_testutils.py │ ├── test_with_data.py │ └── testutils.py ├── notebooks ├── incdbscan-usage.ipynb └── performance.ipynb ├── notes └── notes-on-paper.md ├── poetry.lock ├── profiling.py └── pyproject.toml /.github/workflows/test-code.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test code with tests on Python 3.10 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies with Poetry 27 | run: | 28 | python -m pip install --upgrade pip 29 | curl -sSL https://install.python-poetry.org | python3 - 30 | poetry install 31 | - name: Lint 32 | run: | 33 | poetry run make lint 34 | poetry run make isort-check 35 | - name: Test 36 | run: | 37 | poetry run make test 38 | - name: Slow test 39 | run: | 40 | poetry run make test-slow 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints 2 | .pytest_cache/* 3 | .vscode/* 4 | .ignore/* 5 | .idea/* 6 | src/__pytest__/* 7 | *pyc 8 | profiling/* 9 | *.egg-info/ 10 | .python-version 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Arpad Fulop 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the 14 | names of its contributors may be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | isort: 2 | isort . 3 | 4 | isort-check: 5 | isort . -c -v 6 | 7 | lint: 8 | pylint \ 9 | --disable=missing-class-docstring \ 10 | --disable=missing-function-docstring \ 11 | --disable=missing-module-docstring \ 12 | --disable=too-few-public-methods \ 13 | --fail-under 5 \ 14 | --fail-on E,F \ 15 | incdbscan/* 16 | 17 | test: 18 | python -m pytest -m "not slow" incdbscan/tests/* 19 | 20 | test-slow: 21 | python -m pytest -m slow incdbscan/tests/* 22 | 23 | profile: 24 | mkdir -p profiling 25 | python profiling.py $(tag) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IncrementalDBSCAN 2 | 3 | `incdbscan` is an implementation of IncrementalDBSCAN, the incremental version of the DBSCAN clustering algorithm. 4 | 5 | IncrementalDBSCAN lets the user update the clustering by inserting or deleting data points. The algorithm yields the same result as DBSCAN but without reapplying DBSCAN to the modified data set. 6 | 7 | Thus, IncrementalDBSCAN is ideal to use when the size of the data set to cluster is so large that applying DBSCAN to the whole data set would be costly but for the purpose of the application it is enough to update an already existing clustering by inserting or deleting some data points. 8 | 9 | The implementation is based on the following paper. To see what's new compared to the paper, jump to [Notes on the IncrementalDBSCAN paper](https://github.com/DataOmbudsman/incdbscan/blob/master/notes/notes-on-paper.md). 10 | 11 | > Ester, Martin; Kriegel, Hans-Peter; Sander, Jörg; Wimmer, Michael; Xu, Xiaowei (1998). *Incremental Clustering for Mining in a Data Warehousing Environment.* In: Proceedings of the 24rd International Conference on Very Large Data Bases (VLDB 1998). 12 | 13 |

14 | indbscan illustration 15 |

16 | 17 | ## Table of Contents 18 | 19 | - [Highlights](#Highlights) 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Performance](#Performance) 23 | 24 | ## Highlights 25 | 26 | The `incdbscan` package is an implementation of the IncrementalDBSCAN algorithm by Ester et al., with about 40 unit tests covering diverse cases, and with [additional corrections](https://github.com/DataOmbudsman/incdbscan/blob/master/notes/notes-on-paper.md) to the original paper. 27 | 28 | ## Installation 29 | 30 | `incdbscan` is on PyPI, and can be installed with `pip`: 31 | ``` 32 | pip install incdbscan 33 | ``` 34 | 35 | The latest version of the package requires at least Python 3.9. 36 | 37 | ## Usage 38 | 39 | The algorithm is implemented in the `IncrementalDBSCAN` class. 40 | 41 | There are 3 methods to use: 42 | - `insert` for inserting data points into the clustering 43 | - `delete` for deleting data points from the clustering 44 | - `get_cluster_labels` for obtaining cluster labels 45 | 46 | All methods take a batch of data points in the form of an array of shape `(n_samples, n_features)` (similar to the `scikit-learn` API). 47 | 48 | ```python 49 | from sklearn.datasets import load_iris 50 | X = load_iris()['data'] 51 | X_1, X_2 = X[:100], X[100:] 52 | 53 | from incdbscan import IncrementalDBSCAN 54 | clusterer = IncrementalDBSCAN(eps=0.5, min_pts=5) 55 | 56 | # Insert 1st batch of data points and get their labels 57 | clusterer.insert(X_1) 58 | labels_part1 = clusterer.get_cluster_labels(X_1) 59 | 60 | # Insert 2nd batch and get labels of all points in a one-liner 61 | labels_all = clusterer.insert(X_2).get_cluster_labels(X) 62 | 63 | # Delete 1st batch and get labels for 2nd batch 64 | clusterer.delete(X_1) 65 | labels_part2 = clusterer.get_cluster_labels(X_2) 66 | ``` 67 | 68 | For a longer description of usage check out the [notebook](https://github.com/DataOmbudsman/incdbscan/blob/master/notebooks/incdbscan-usage.ipynb) developed just for that! 69 | 70 | ## Performance 71 | 72 | Performance has two components: insertion and deletion cost. 73 | 74 |

75 | indbscan performance 76 |

77 | 78 | The cost of **inserting** a new data point into IncrementalDBSCAN is quite small and **grows slower** than the cost of applying (`scikit-learns`'s) DBSCAN to a whole data set. In other words, *given that* we have a data set _D_ clustered with IncrementalDBSCAN, and we want to see which cluster would a new object _P_ belong to, it is faster to insert _P_ into the current IncrementalDBSCAN clustering than to apply DBSCAN to the union of _D_ and _P_. 79 | 80 | The cost of **deleting** a data point from IncrementalDBSCAN **grows faster** than the cost of applying (`scikit-learns`'s) DBSCAN to the data set minus that data point. Thus, the cost of deletion in IncrementalDBSCAN is quite small below a certain data set size, but becomes larger as data set size grows. 81 | 82 | These results do not imply that it is very efficient to cluster a whole data set with a series of IncrementalDBSCAN insertions. If we measure the time to cluster a data set with DBSCAN versus to cluster the data by adding the data points one by one to IncrementalDBSCAN, IncrementalDBSCAN will be slower compared to DBSCAN. A typical performance number is that clustering 8,000 data points takes about 10-20 seconds with this implementation. 83 | 84 | See [this notebook](https://github.com/DataOmbudsman/incdbscan/blob/master/notebooks/performance.ipynb) about performance for more details. 85 | 86 | ### Known limitations 87 | 88 | - **Batch insertion**: In the current implementation batch insertion of data points is not efficient, since pairwise distance calculation between new and existing data points is not yet vectorized. 89 | - **Deletion**: Data point deletion can take long in big data sets (big clusters) because of a graph traversal step. There isn't any clear direction of making it more efficient algorithmically. 90 | -------------------------------------------------------------------------------- /images/illustration_circles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataOmbudsman/incdbscan/2488c191fbd805e6f116d773986d25402e73a9c6/images/illustration_circles.gif -------------------------------------------------------------------------------- /images/performance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataOmbudsman/incdbscan/2488c191fbd805e6f116d773986d25402e73a9c6/images/performance.png -------------------------------------------------------------------------------- /incdbscan/__init__.py: -------------------------------------------------------------------------------- 1 | from .incrementaldbscan import ( 2 | IncrementalDBSCAN, 3 | IncrementalDBSCANWarning 4 | ) 5 | 6 | 7 | __version__ = '0.3.0' 8 | -------------------------------------------------------------------------------- /incdbscan/_bfscomponentfinder.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import ( 3 | Dict, 4 | List 5 | ) 6 | 7 | import rustworkx as rx 8 | from rustworkx.visit import ( 9 | BFSVisitor, 10 | PruneSearch 11 | ) 12 | 13 | from ._object import ( 14 | NodeId, 15 | Object 16 | ) 17 | 18 | 19 | class BFSComponentFinder(BFSVisitor): 20 | 21 | # Traverse the objects in a BFS manner to find those components of 22 | # objects that need to be split away. A component here is a group of 23 | # objects that all can be linked to the same seed object. Starting from 24 | # the seed objects, expand the graph by adding neighboring objects. 25 | # Note that it could be even faster if the traversal terminted when all of 26 | # the next nodes to be visited are linked to the same seed object -- this 27 | # means that all but one component are traversed completely and they can 28 | # be split away. 29 | 30 | def __init__(self, graph): 31 | self.graph: rx.PyGraph = graph # graph of Objects # pylint: disable=no-member 32 | self.seed_to_component: Dict[NodeId, List[Object]] = defaultdict(set) 33 | self._node_to_seed: Dict[NodeId, NodeId] = defaultdict(int) 34 | 35 | def discover_vertex(self, vertex_node_id): 36 | # If this is the first time discovering a node then the node itself 37 | # will be its own seed. This is the way we keep track of singleton 38 | # nodes (i.e., ones without edges). 39 | 40 | if vertex_node_id not in self._node_to_seed: 41 | self._node_to_seed[vertex_node_id] = vertex_node_id 42 | self.seed_to_component[vertex_node_id].add(self.graph[vertex_node_id]) 43 | 44 | # If the node does not represent a core object then we don't want 45 | # traversal to go in that direction. 46 | 47 | if not self.graph[vertex_node_id].is_core: 48 | raise PruneSearch 49 | 50 | def tree_edge(self, edge): 51 | source_node_id, target_node_id, _ = edge 52 | 53 | # The target of the edge is a new node we see for the first time. Its 54 | # seed will be the seed of the source. 55 | 56 | seed = self._node_to_seed[source_node_id] 57 | self._node_to_seed[target_node_id] = seed 58 | self.seed_to_component[seed].add(self.graph[target_node_id]) 59 | 60 | def non_tree_edge(self, edge): 61 | source_node_id, target_node_id, _ = edge 62 | 63 | # A non-tree edge is the case of merge, that is, when two components 64 | # with different seeds meet. However, we only merge them if the target 65 | # represents a core object in the graph (i.e., dense connection). 66 | 67 | source_seed = self._node_to_seed[source_node_id] 68 | target_seed = self._node_to_seed[target_node_id] 69 | different_seeds = source_seed != target_seed 70 | 71 | if different_seeds and self.graph[target_node_id].is_core: 72 | if source_seed > target_seed: 73 | self._node_to_seed[target_node_id] = source_seed 74 | else: 75 | self._node_to_seed[source_node_id] = target_seed 76 | 77 | seed = self._node_to_seed[target_node_id] 78 | self.seed_to_component[seed].add(self.graph[target_node_id]) 79 | -------------------------------------------------------------------------------- /incdbscan/_deleter.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import rustworkx as rx 4 | 5 | from ._bfscomponentfinder import BFSComponentFinder 6 | from ._labels import CLUSTER_LABEL_NOISE 7 | 8 | 9 | class Deleter: 10 | def __init__(self, eps, min_pts, objects): 11 | self.eps = eps 12 | self.min_pts = min_pts 13 | self.objects = objects 14 | 15 | def delete(self, object_to_delete): 16 | self.objects.delete_object(object_to_delete) 17 | object_deleted = object_to_delete 18 | 19 | ex_cores = self._get_objects_that_lost_core_property(object_deleted) 20 | 21 | update_seeds, non_core_neighbors_of_ex_cores = \ 22 | self._get_update_seeds_and_non_core_neighbors_of_ex_cores( 23 | ex_cores, object_deleted) 24 | 25 | if update_seeds: 26 | # Only for update seeds belonging to the same cluster do we 27 | # have to consider if split is needed. 28 | 29 | update_seeds_by_cluster = \ 30 | self._group_objects_by_cluster(update_seeds) 31 | 32 | for seeds in update_seeds_by_cluster.values(): 33 | components = self._find_components_to_split_away(seeds) 34 | for component in components: 35 | self.objects.set_labels( 36 | component, self.objects.get_next_cluster_label()) 37 | 38 | # Updating labels of border objects that were in the neighborhood 39 | # of objects that lost their core property is always needed. They 40 | # become either borders of other clusters or noise. 41 | 42 | self._set_each_border_object_labels_to_largest_around( 43 | non_core_neighbors_of_ex_cores) 44 | 45 | def _get_objects_that_lost_core_property(self, object_deleted): 46 | for obj in object_deleted.neighbors: 47 | if obj.neighbor_count == self.min_pts - 1: 48 | yield obj 49 | 50 | # The result has to contain the deleted object if it was core 51 | if object_deleted.is_core: 52 | yield object_deleted 53 | 54 | def _get_update_seeds_and_non_core_neighbors_of_ex_cores( 55 | self, 56 | ex_cores, 57 | object_deleted): 58 | 59 | update_seeds = set() 60 | non_core_neighbors_of_ex_cores = set() 61 | 62 | for ex_core in ex_cores: 63 | # The is-core property of objects that became non core need to be 64 | # re-cached 65 | ex_core._clear_is_core_cache() 66 | for neighbor in ex_core.neighbors: 67 | if neighbor.is_core: 68 | update_seeds.add(neighbor) 69 | else: 70 | non_core_neighbors_of_ex_cores.add(neighbor) 71 | 72 | if object_deleted.count == 0: 73 | update_seeds = update_seeds.difference({object_deleted}) 74 | non_core_neighbors_of_ex_cores = \ 75 | non_core_neighbors_of_ex_cores.difference({object_deleted}) 76 | 77 | return update_seeds, non_core_neighbors_of_ex_cores 78 | 79 | def _group_objects_by_cluster(self, objects): 80 | grouped_objects = defaultdict(list) 81 | 82 | for obj in objects: 83 | label = self.objects.get_label(obj) 84 | grouped_objects[label].append(obj) 85 | 86 | return grouped_objects 87 | 88 | def _find_components_to_split_away(self, seed_objects): 89 | if len(seed_objects) == 1: 90 | return [] 91 | 92 | if self._objects_are_neighbors_of_each_other(seed_objects): 93 | return [] 94 | 95 | seed_node_ids = [obj.node_id for obj in seed_objects] 96 | finder = BFSComponentFinder(self.objects.graph) 97 | rx.bfs_search(self.objects.graph, seed_node_ids, finder) 98 | 99 | seed_of_largest, size_of_largest = 0, 0 100 | for seed_id, component in finder.seed_to_component.items(): 101 | component_size = len(component) 102 | if component_size > size_of_largest: 103 | size_of_largest = component_size 104 | seed_of_largest = seed_id 105 | 106 | for seed_id, component in finder.seed_to_component.items(): 107 | if seed_id != seed_of_largest: 108 | yield component 109 | 110 | @staticmethod 111 | def _objects_are_neighbors_of_each_other(objects): 112 | for obj1 in objects: 113 | for obj2 in objects: 114 | if obj2 not in obj1.neighbors: 115 | return False 116 | return True 117 | 118 | def _set_each_border_object_labels_to_largest_around(self, objects_to_set): 119 | cluster_updates = {} 120 | 121 | for obj in objects_to_set: 122 | labels = self._get_cluster_labels_in_neighborhood(obj) 123 | if not labels: 124 | labels.add(CLUSTER_LABEL_NOISE) 125 | 126 | cluster_updates[obj] = max(labels) 127 | 128 | for obj, new_cluster_label in cluster_updates.items(): 129 | self.objects.set_label(obj, new_cluster_label) 130 | 131 | def _get_cluster_labels_in_neighborhood(self, obj): 132 | return {self.objects.get_label(neighbor) 133 | for neighbor in obj.neighbors 134 | if neighbor.is_core} 135 | -------------------------------------------------------------------------------- /incdbscan/_inserter.py: -------------------------------------------------------------------------------- 1 | from ._labels import ( 2 | CLUSTER_LABEL_NOISE, 3 | CLUSTER_LABEL_UNCLASSIFIED 4 | ) 5 | 6 | 7 | class Inserter: 8 | def __init__(self, eps, min_pts, objects): 9 | self.eps = eps 10 | self.min_pts = min_pts 11 | self.objects = objects 12 | 13 | def insert(self, object_value): 14 | object_inserted = self.objects.insert_object(object_value) 15 | 16 | new_core_neighbors, old_core_neighbors = \ 17 | self._separate_core_neighbors_by_novelty(object_inserted) 18 | 19 | if not new_core_neighbors: 20 | # If there is no new core object, only the new object has to be 21 | # put in a cluster. 22 | 23 | if old_core_neighbors: 24 | # If there are already core objects near to the new object, 25 | # the new object is put in the most recent cluster. This is 26 | # similar to case "Absorption" in the paper but not defined 27 | # there. 28 | 29 | label_of_new_object = max([ 30 | self.objects.get_label(obj) for obj in old_core_neighbors 31 | ]) 32 | 33 | else: 34 | # If the new object does not have any core neighbors, 35 | # it becomes a noise. Called case "Noise" in the paper. 36 | 37 | label_of_new_object = CLUSTER_LABEL_NOISE 38 | 39 | self.objects.set_label(object_inserted, label_of_new_object) 40 | return 41 | 42 | update_seeds = self._get_update_seeds(new_core_neighbors) 43 | 44 | connected_components_in_update_seeds = \ 45 | self.objects.get_connected_components_within_objects(update_seeds) 46 | 47 | for component in connected_components_in_update_seeds: 48 | effective_cluster_labels = \ 49 | self._get_effective_cluster_labels_of_objects(component) 50 | 51 | if not effective_cluster_labels: 52 | # If in a connected component of update seeds there are only 53 | # previously unclassified and noise objects, a new cluster is 54 | # created. Corresponds to case "Creation" in the paper. 55 | 56 | next_cluster_label = self.objects.get_next_cluster_label() 57 | self.objects.set_labels(component, next_cluster_label) 58 | 59 | else: 60 | # If in a connected component of update seeds there are 61 | # already clustered objects, all objects in the component 62 | # will be merged into the most recent cluster. 63 | # Corresponds to cases "Absorption" and "Merge" in the paper. 64 | 65 | max_label = max(effective_cluster_labels) 66 | self.objects.set_labels(component, max_label) 67 | 68 | for label in effective_cluster_labels: 69 | self.objects.change_labels(label, max_label) 70 | 71 | # Finally all neighbors of each new core object inherits a label from 72 | # its new core neighbor, thereby affecting border and noise objects, 73 | # and the object being inserted. 74 | 75 | self._set_cluster_label_around_new_core_neighbors(new_core_neighbors) 76 | 77 | def _separate_core_neighbors_by_novelty(self, object_inserted): 78 | new_cores = set() 79 | old_cores = set() 80 | 81 | for obj in object_inserted.neighbors: 82 | if obj.neighbor_count == self.min_pts: 83 | new_cores.add(obj) 84 | elif obj.neighbor_count > self.min_pts: 85 | old_cores.add(obj) 86 | 87 | # If the inserted object is core, it is a new core 88 | 89 | if object_inserted in old_cores: 90 | old_cores.remove(object_inserted) 91 | new_cores.add(object_inserted) 92 | 93 | return new_cores, old_cores 94 | 95 | def _get_update_seeds(self, new_core_neighbors): 96 | seeds = set() 97 | 98 | for new_core_neighbor in new_core_neighbors: 99 | core_neighbors = [obj for obj in new_core_neighbor.neighbors 100 | if obj.neighbor_count >= self.min_pts] 101 | seeds.update(core_neighbors) 102 | 103 | return seeds 104 | 105 | def _get_effective_cluster_labels_of_objects(self, objects): 106 | non_effective_cluster_labels = {CLUSTER_LABEL_UNCLASSIFIED, 107 | CLUSTER_LABEL_NOISE} 108 | effective_cluster_labels = set() 109 | 110 | for obj in objects: 111 | label = self.objects.get_label(obj) 112 | if label not in non_effective_cluster_labels: 113 | effective_cluster_labels.add(label) 114 | 115 | return effective_cluster_labels 116 | 117 | def _set_cluster_label_around_new_core_neighbors(self, new_core_neighbors): 118 | for obj in new_core_neighbors: 119 | label = self.objects.get_label(obj) 120 | self.objects.set_labels(obj.neighbors, label) 121 | -------------------------------------------------------------------------------- /incdbscan/_labels.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | 4 | ClusterLabel = int 5 | 6 | CLUSTER_LABEL_UNCLASSIFIED: ClusterLabel = -2 7 | CLUSTER_LABEL_NOISE: ClusterLabel = -1 8 | CLUSTER_LABEL_FIRST_CLUSTER: ClusterLabel = 0 9 | 10 | 11 | class LabelHandler: 12 | def __init__(self): 13 | self._label_to_objects = defaultdict(set) 14 | self._object_to_label = {} 15 | 16 | def set_label(self, obj, label): 17 | previous_label = self._object_to_label[obj] 18 | self._label_to_objects[previous_label].remove(obj) 19 | self._label_to_objects[label].add(obj) 20 | self._object_to_label[obj] = label 21 | 22 | def set_label_of_inserted_object(self, obj): 23 | self._object_to_label[obj] = CLUSTER_LABEL_UNCLASSIFIED 24 | self._label_to_objects[CLUSTER_LABEL_UNCLASSIFIED].add(obj) 25 | 26 | def set_labels(self, objects, label): 27 | for obj in objects: 28 | self.set_label(obj, label) 29 | 30 | def delete_label_of_deleted_object(self, obj): 31 | label = self.get_label(obj) 32 | self._label_to_objects[label].remove(obj) 33 | 34 | def get_label(self, obj): 35 | return self._object_to_label[obj] 36 | 37 | def get_next_cluster_label(self): 38 | return max(self._label_to_objects.keys()) + 1 39 | 40 | def change_labels(self, change_from, change_to): 41 | affected_objects = self._label_to_objects.pop(change_from) 42 | self._label_to_objects[change_to].update(affected_objects) 43 | 44 | for obj in affected_objects: 45 | self._object_to_label[obj] = change_to 46 | -------------------------------------------------------------------------------- /incdbscan/_neighbor_searcher.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from sklearn.neighbors import NearestNeighbors 3 | from sortedcontainers import SortedList 4 | 5 | 6 | class NeighborSearcher: 7 | def __init__(self, radius, metric, p): 8 | self.neighbor_searcher = \ 9 | NearestNeighbors(radius=radius, metric=metric, p=p) 10 | self.values = np.array([]) 11 | self.ids = SortedList() 12 | 13 | def insert(self, new_value, new_id): 14 | self.ids.add(new_id) 15 | position = self.ids.index(new_id) 16 | 17 | self._insert_into_array(new_value, position) 18 | self.neighbor_searcher = self.neighbor_searcher.fit(self.values) 19 | 20 | def _insert_into_array(self, new_value, position): 21 | extended = np.insert(self.values, position, new_value, axis=0) 22 | if not self.values.size: 23 | extended = extended.reshape(1, -1) 24 | self.values = extended 25 | 26 | def query_neighbors(self, query_value): 27 | neighbor_indices = self.neighbor_searcher.radius_neighbors( 28 | [query_value], return_distance=False)[0] 29 | 30 | for ix in neighbor_indices: 31 | yield self.ids[ix] 32 | 33 | def delete(self, id_): 34 | position = self.ids.index(id_) 35 | del self.ids[position] 36 | self.values = np.delete(self.values, position, axis=0) 37 | -------------------------------------------------------------------------------- /incdbscan/_object.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | 3 | 4 | NodeId = int 5 | ObjectId = int 6 | 7 | 8 | class Object: 9 | def __init__(self, id_, min_pts): 10 | self.id: ObjectId = id_ 11 | self.node_id: NodeId = None 12 | self.count = 1 13 | self.neighbors = {self} 14 | self.neighbor_count = 0 15 | self.min_pts = min_pts 16 | 17 | @cached_property 18 | def is_core(self): 19 | # Note that this property is only valid during deletion 20 | return self.neighbor_count >= self.min_pts 21 | 22 | def _clear_is_core_cache(self): 23 | self.__dict__.pop('is_core', None) 24 | 25 | def __repr__(self): 26 | return f'{self.id}_' 27 | -------------------------------------------------------------------------------- /incdbscan/_objects.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Dict, 3 | List, 4 | Set 5 | ) 6 | 7 | import rustworkx as rx 8 | 9 | from ._labels import LabelHandler 10 | from ._neighbor_searcher import NeighborSearcher 11 | from ._object import ( 12 | NodeId, 13 | Object, 14 | ObjectId 15 | ) 16 | from ._utils import hash_ 17 | 18 | 19 | class Objects(LabelHandler): 20 | def __init__(self, eps, min_pts, metric, p): 21 | super().__init__() 22 | 23 | self.graph = rx.PyGraph(multigraph=False) # pylint: disable=no-member 24 | self._object_id_to_node_id: Dict[ObjectId, NodeId] = {} 25 | 26 | self.neighbor_searcher = \ 27 | NeighborSearcher(radius=eps, metric=metric, p=p) 28 | self.min_pts = min_pts 29 | 30 | def get_object(self, value): 31 | object_id = hash_(value) 32 | if object_id in self._object_id_to_node_id: 33 | obj = self._get_object_from_object_id(object_id) 34 | return obj 35 | return None 36 | 37 | def insert_object(self, value): 38 | object_id = hash_(value) 39 | 40 | if object_id in self._object_id_to_node_id: 41 | obj = self._get_object_from_object_id(object_id) 42 | obj.count += 1 43 | for neighbor in obj.neighbors: 44 | neighbor.neighbor_count += 1 45 | return obj 46 | 47 | new_object = Object(object_id, self.min_pts) 48 | 49 | self._insert_graph_metadata(new_object) 50 | self.set_label_of_inserted_object(new_object) 51 | self.neighbor_searcher.insert(value, object_id) 52 | self._update_neighbors_during_insertion(new_object, value) 53 | return new_object 54 | 55 | def _insert_graph_metadata(self, new_object): 56 | node_id = self.graph.add_node(new_object) 57 | new_object.node_id = node_id 58 | object_id = new_object.id 59 | self._object_id_to_node_id[object_id] = node_id 60 | 61 | def _update_neighbors_during_insertion(self, object_inserted, new_value): 62 | neighbors = self._get_neighbors(new_value) 63 | for obj in neighbors: 64 | obj.neighbor_count += 1 65 | if obj.id != object_inserted.id: 66 | object_inserted.neighbor_count += obj.count 67 | obj.neighbors.add(object_inserted) 68 | object_inserted.neighbors.add(obj) 69 | self.graph.add_edge(object_inserted.node_id, obj.node_id, None) 70 | 71 | def _get_neighbors(self, query_value): 72 | neighbor_ids = self.neighbor_searcher.query_neighbors(query_value) 73 | 74 | for id_ in neighbor_ids: 75 | obj = self._get_object_from_object_id(id_) 76 | yield obj 77 | 78 | def _get_object_from_object_id(self, object_id): 79 | node_id = self._object_id_to_node_id[object_id] 80 | obj = self.graph[node_id] 81 | return obj 82 | 83 | def delete_object(self, obj): 84 | obj.count -= 1 85 | remove_from_data = obj.count == 0 86 | 87 | for neighbor in obj.neighbors: 88 | neighbor.neighbor_count -= 1 89 | if remove_from_data: 90 | if neighbor.id != obj.id: 91 | neighbor.neighbors.remove(obj) 92 | 93 | if remove_from_data: 94 | self._delete_graph_metadata(obj) 95 | self.neighbor_searcher.delete(obj.id) 96 | self.delete_label_of_deleted_object(obj) 97 | 98 | def _delete_graph_metadata(self, deleted_object): 99 | node_id = deleted_object.node_id 100 | self.graph.remove_node(node_id) 101 | del self._object_id_to_node_id[deleted_object.id] 102 | 103 | def get_connected_components_within_objects( 104 | self, objects: Set[Object]) -> List[Set[Object]]: 105 | 106 | if len(objects) == 1: 107 | return [objects] 108 | 109 | node_ids = [obj.node_id for obj in objects] 110 | subgraph = self.graph.subgraph(node_ids) 111 | components_as_ids: List[Set[NodeId]] = rx.connected_components(subgraph) # pylint: disable=no-member 112 | 113 | def _get_original_object(subgraph, subgraph_node_id): 114 | original_node_id = subgraph[subgraph_node_id].node_id 115 | return self.graph[original_node_id] 116 | 117 | components_as_objects = [] 118 | for component in components_as_ids: 119 | component_objects = { 120 | _get_original_object(subgraph, subgraph_node_id) 121 | for subgraph_node_id in component 122 | } 123 | components_as_objects.append(component_objects) 124 | 125 | return components_as_objects 126 | -------------------------------------------------------------------------------- /incdbscan/_utils.py: -------------------------------------------------------------------------------- 1 | import xxhash 2 | from sklearn.utils.validation import check_array 3 | 4 | 5 | def hash_(array): 6 | return xxhash.xxh64_intdigest(array.tobytes()) >> 1 7 | 8 | 9 | def input_check(X): 10 | return check_array(X, dtype=float, accept_large_sparse=False) 11 | -------------------------------------------------------------------------------- /incdbscan/incrementaldbscan.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import numpy as np 4 | 5 | from ._deleter import Deleter 6 | from ._inserter import Inserter 7 | from ._objects import Objects 8 | from ._utils import input_check 9 | 10 | 11 | class IncrementalDBSCAN: 12 | """The incremental version of DBSCAN, a density-based clustering algorithm 13 | that handles outliers. 14 | 15 | After an initial clustering of an initial object set (i.e., set of data 16 | points), the object set can at any time be updated by increments of any 17 | size. An increment can be either the insertion or the deletion of objects. 18 | 19 | After each update, the result of the clustering is the same as if the 20 | updated object set (i.e., the initial object set modified by all 21 | increments) was clustered by DBSCAN. However, this result is reached by 22 | using information from the previous state of the clustering, and without 23 | the need of applying DBSCAN to the whole updated object set. 24 | 25 | Parameters 26 | ---------- 27 | eps : float, optional (default=0.5) 28 | The radius of neighborhood calculation. An object is the neighbor of 29 | another if the distance between them is no more than eps. 30 | 31 | min_pts : int, optional (default=1) 32 | The minimum number of neighbors that an object needs to have to be a 33 | core object of a cluster. 34 | 35 | metric : string or callable, optional (default='minkowski') 36 | The distance metric to use to calculate distance between data objects. 37 | Accepts metrics that are accepted by scikit-learn's NearestNeighbors 38 | class, excluding 'precomputed'. The default is 'minkowski', which is 39 | equivalent to the Euclidean distance if p=2. 40 | 41 | p : float or int, optional (default=2) 42 | Parameter for Minkowski distance if metric='minkowski'. 43 | 44 | References 45 | ---------- 46 | Ester et al. 1998. Incremental Clustering for Mining in a Data Warehousing 47 | Environment. In: Proceedings of the 24th International Conference on Very 48 | Large Data Bases (VLDB 1998). 49 | 50 | """ 51 | 52 | def __init__(self, eps=1, min_pts=5, metric='minkowski', p=2): 53 | self.eps = eps 54 | self.min_pts = min_pts 55 | self.metric = metric 56 | self.p = p 57 | 58 | self._objects = Objects(self.eps, self.min_pts, self.metric, self.p) 59 | self._inserter = Inserter(self.eps, self.min_pts, self._objects) 60 | self._deleter = Deleter(self.eps, self.min_pts, self._objects) 61 | 62 | def insert(self, X): 63 | """Insert objects into the object set, then update clustering. 64 | 65 | Parameters 66 | ---------- 67 | X : array-like of shape (n_samples, n_features) 68 | The data objects to be inserted into the object set. 69 | 70 | Returns 71 | ------- 72 | self 73 | 74 | """ 75 | X = input_check(X) 76 | 77 | for value in X: 78 | self._inserter.insert(value) 79 | 80 | return self 81 | 82 | def delete(self, X): 83 | """Delete objects from object set, then update clustering. 84 | 85 | Parameters 86 | ---------- 87 | X : array-like of shape (n_samples, n_features) 88 | The data objects to be deleted from the object set. 89 | 90 | Returns 91 | ------- 92 | self 93 | 94 | """ 95 | X = input_check(X) 96 | 97 | for ix, value in enumerate(X): 98 | obj = self._objects.get_object(value) 99 | 100 | if obj: 101 | self._deleter.delete(obj) 102 | 103 | else: 104 | warnings.warn( 105 | IncrementalDBSCANWarning( 106 | f'Object at position {ix} was not deleted because ' 107 | 'there is no such object in the object set.' 108 | ) 109 | ) 110 | 111 | return self 112 | 113 | def get_cluster_labels(self, X): 114 | """Get cluster labels of objects. 115 | 116 | Parameters 117 | ---------- 118 | X : array-like of shape (n_samples, n_features) 119 | The data objects to get labels for. 120 | 121 | Returns 122 | ------- 123 | labels : ndarray of shape (n_samples,) 124 | Cluster labels. Effective labels start from 0. -1 means the 125 | object is noise. numpy.nan means the object was not in the 126 | object set. 127 | 128 | """ 129 | X = input_check(X) 130 | 131 | labels = np.zeros(len(X)) 132 | 133 | for ix, value in enumerate(X): 134 | obj = self._objects.get_object(value) 135 | 136 | if obj: 137 | label = self._objects.get_label(obj) 138 | 139 | else: 140 | label = np.nan 141 | warnings.warn( 142 | IncrementalDBSCANWarning( 143 | f'No label was retrieved for object at position {ix} ' 144 | 'because there is no such object in the object set.' 145 | ) 146 | ) 147 | 148 | labels[ix] = label 149 | 150 | return labels 151 | 152 | 153 | class IncrementalDBSCANWarning(Warning): 154 | pass 155 | -------------------------------------------------------------------------------- /incdbscan/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from sklearn.datasets import make_blobs 4 | 5 | from incdbscan import IncrementalDBSCAN 6 | 7 | 8 | EPS = 1.5 9 | 10 | 11 | @pytest.fixture 12 | def incdbscan3(): 13 | return IncrementalDBSCAN(eps=EPS, min_pts=3) 14 | 15 | 16 | @pytest.fixture 17 | def incdbscan4(): 18 | return IncrementalDBSCAN(eps=EPS, min_pts=4) 19 | 20 | 21 | @pytest.fixture 22 | def blob_in_middle(): 23 | # pylint: disable=unbalanced-tuple-unpacking 24 | blob, _ = make_blobs( 25 | n_samples=10, 26 | centers=[[0, 0]], 27 | n_features=2, 28 | cluster_std=0.4, 29 | random_state=123, 30 | return_centers=False 31 | ) 32 | return blob 33 | 34 | 35 | @pytest.fixture 36 | def object_far_away(): 37 | return np.array([[10., 10.]]) 38 | 39 | 40 | @pytest.fixture 41 | def point_at_origin(): 42 | return np.array([[0., 0.]]) 43 | 44 | 45 | @pytest.fixture 46 | def three_points_on_the_left(): 47 | return np.array([ 48 | [-EPS, 0], 49 | [-EPS * 2, 0], 50 | [-EPS * 3, 0], 51 | ]) 52 | 53 | 54 | @pytest.fixture 55 | def three_points_on_the_top(): 56 | return np.array([ 57 | [0, EPS], 58 | [0, EPS * 2], 59 | [0, EPS * 3], 60 | ]) 61 | 62 | 63 | @pytest.fixture 64 | def three_points_at_the_bottom(): 65 | return np.array([ 66 | [0, -EPS], 67 | [0, -EPS * 2], 68 | [0, -EPS * 3], 69 | ]) 70 | 71 | 72 | @pytest.fixture 73 | def hourglass_on_the_right(): 74 | return np.array([ 75 | [EPS, EPS * 2], 76 | [EPS, EPS * 2], 77 | [EPS, EPS], 78 | [EPS, 0], 79 | [EPS, -EPS], 80 | [EPS, -EPS * 2], 81 | [EPS, -EPS * 2], 82 | ]) 83 | -------------------------------------------------------------------------------- /incdbscan/tests/test_deleter.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from conftest import EPS 3 | 4 | from testutils import ( 5 | CLUSTER_LABEL_FIRST_CLUSTER, 6 | CLUSTER_LABEL_NOISE, 7 | assert_cluster_labels, 8 | assert_label_of_object_is_among_possible_ones, 9 | assert_split_creates_new_labels_for_new_clusters, 10 | insert_objects_then_assert_cluster_labels, 11 | reflect_horizontally 12 | ) 13 | 14 | 15 | def test_after_deleting_enough_objects_only_noise_remain( 16 | incdbscan4, 17 | blob_in_middle): 18 | 19 | incdbscan4.insert(blob_in_middle) 20 | 21 | for i in range(len(blob_in_middle) - 1): 22 | object_to_delete = blob_in_middle[[i]] 23 | 24 | incdbscan4.delete(object_to_delete) 25 | 26 | expected_label = ( 27 | CLUSTER_LABEL_NOISE 28 | if i > incdbscan4.min_pts + 1 29 | else CLUSTER_LABEL_FIRST_CLUSTER 30 | ) 31 | 32 | assert_cluster_labels(incdbscan4, blob_in_middle[i+1:], expected_label) 33 | 34 | 35 | def test_deleting_cores_only_makes_borders_noise(incdbscan4, point_at_origin): 36 | point_to_delete = point_at_origin 37 | incdbscan4.insert(point_to_delete) 38 | 39 | border = np.array([ 40 | [EPS, 0], 41 | [0, EPS], 42 | [0, -EPS], 43 | ]) 44 | 45 | incdbscan4.insert(border) 46 | incdbscan4.delete(point_to_delete) 47 | 48 | assert_cluster_labels(incdbscan4, border, CLUSTER_LABEL_NOISE) 49 | 50 | 51 | def test_objects_losing_core_property_can_keep_cluster_id( 52 | incdbscan3, 53 | point_at_origin): 54 | 55 | point_to_delete = point_at_origin 56 | 57 | core_points = np.array([ 58 | [EPS, 0], 59 | [0, EPS], 60 | [EPS, EPS], 61 | ]) 62 | 63 | all_points = np.vstack([point_to_delete, core_points]) 64 | 65 | insert_objects_then_assert_cluster_labels( 66 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 67 | 68 | incdbscan3.delete(point_to_delete) 69 | assert_cluster_labels(incdbscan3, core_points, CLUSTER_LABEL_FIRST_CLUSTER) 70 | 71 | 72 | def test_border_object_can_switch_to_other_cluster( 73 | incdbscan4, 74 | point_at_origin): 75 | 76 | border = point_at_origin 77 | incdbscan4.insert(border) 78 | 79 | cluster_1 = np.array([ 80 | [EPS, 0], 81 | [EPS, EPS], 82 | [EPS, -EPS], 83 | ]) 84 | cluster_1_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 85 | 86 | cluster_2 = reflect_horizontally(cluster_1) 87 | cluster_2_expected_label = cluster_1_expected_label + 1 88 | 89 | insert_objects_then_assert_cluster_labels( 90 | incdbscan4, cluster_1, cluster_1_expected_label) 91 | 92 | insert_objects_then_assert_cluster_labels( 93 | incdbscan4, cluster_2, cluster_2_expected_label 94 | ) 95 | 96 | assert_cluster_labels(incdbscan4, border, cluster_2_expected_label) 97 | 98 | incdbscan4.delete(cluster_2[[0]]) 99 | 100 | assert_cluster_labels(incdbscan4, border, cluster_1_expected_label) 101 | 102 | 103 | def test_borders_around_point_losing_core_property_can_become_noise( 104 | incdbscan4, 105 | point_at_origin): 106 | 107 | point_to_delete = point_at_origin 108 | 109 | core = np.array([[0, EPS]]) 110 | 111 | border = np.array([ 112 | [0, EPS * 2], 113 | [EPS, EPS] 114 | ]) 115 | 116 | all_points = np.vstack([point_to_delete, core, border]) 117 | all_points_but_point_to_delete = np.vstack([core, border]) 118 | 119 | insert_objects_then_assert_cluster_labels( 120 | incdbscan4, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 121 | 122 | incdbscan4.delete(point_to_delete) 123 | 124 | assert_cluster_labels( 125 | incdbscan4, all_points_but_point_to_delete, CLUSTER_LABEL_NOISE) 126 | 127 | 128 | def test_core_property_of_singleton_update_seed_is_kept_after_deletion( 129 | incdbscan3, 130 | point_at_origin): 131 | 132 | point_to_delete = point_at_origin 133 | 134 | cores = np.array([ 135 | [EPS, 0], 136 | [2 * EPS, 0], 137 | [2 * EPS, 0], 138 | ]) 139 | 140 | lonely = np.array([[-EPS, 0]]) 141 | 142 | all_points = np.vstack([point_to_delete, cores, lonely]) 143 | 144 | insert_objects_then_assert_cluster_labels( 145 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 146 | 147 | incdbscan3.delete(point_to_delete) 148 | 149 | assert_cluster_labels(incdbscan3, cores, CLUSTER_LABEL_FIRST_CLUSTER) 150 | assert_cluster_labels(incdbscan3, lonely, CLUSTER_LABEL_NOISE) 151 | 152 | 153 | def test_cluster_id_of_single_component_update_seeds_is_kept_after_deletion( 154 | incdbscan3, 155 | point_at_origin): 156 | 157 | point_to_delete = point_at_origin 158 | 159 | cores = np.array([ 160 | [EPS, 0], 161 | [EPS, 0], 162 | [2 * EPS, 0], 163 | ]) 164 | 165 | lonely = np.array([[-EPS, 0]]) 166 | 167 | all_points = np.vstack([point_to_delete, cores, lonely]) 168 | 169 | insert_objects_then_assert_cluster_labels( 170 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 171 | 172 | incdbscan3.delete(point_to_delete) 173 | 174 | assert_cluster_labels(incdbscan3, cores, CLUSTER_LABEL_FIRST_CLUSTER) 175 | assert_cluster_labels(incdbscan3, lonely, CLUSTER_LABEL_NOISE) 176 | 177 | 178 | def test_cluster_id_of_single_component_objects_is_kept_after_deletion( 179 | incdbscan3, 180 | point_at_origin): 181 | 182 | point_to_delete = point_at_origin 183 | 184 | cores = np.array([ 185 | [EPS, 0], 186 | [0, EPS], 187 | [EPS, EPS], 188 | [EPS, EPS], 189 | ]) 190 | 191 | all_points = np.vstack([point_to_delete, cores]) 192 | 193 | insert_objects_then_assert_cluster_labels( 194 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 195 | 196 | incdbscan3.delete(point_to_delete) 197 | 198 | assert_cluster_labels(incdbscan3, cores, CLUSTER_LABEL_FIRST_CLUSTER) 199 | 200 | 201 | def test_simple_two_way_split( 202 | incdbscan3, 203 | point_at_origin, 204 | three_points_on_the_left): 205 | 206 | point_to_delete = point_at_origin 207 | points_left = three_points_on_the_left 208 | points_right = reflect_horizontally(points_left) 209 | 210 | all_points = np.vstack([point_to_delete, points_left, points_right]) 211 | 212 | insert_objects_then_assert_cluster_labels( 213 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 214 | 215 | incdbscan3.delete(point_to_delete) 216 | 217 | assert_split_creates_new_labels_for_new_clusters( 218 | incdbscan3, [points_left, points_right], CLUSTER_LABEL_FIRST_CLUSTER) 219 | 220 | 221 | def test_simple_two_way_split_with_noise( 222 | incdbscan3, 223 | point_at_origin, 224 | three_points_on_the_left, 225 | three_points_on_the_top, 226 | three_points_at_the_bottom): 227 | 228 | point_to_delete = point_at_origin 229 | points_left = three_points_on_the_left 230 | points_top = three_points_on_the_top 231 | points_bottom = three_points_at_the_bottom[:-1] 232 | 233 | all_points = np.vstack([ 234 | point_to_delete, 235 | points_left, 236 | points_top, 237 | points_bottom 238 | ]) 239 | 240 | insert_objects_then_assert_cluster_labels( 241 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 242 | 243 | incdbscan3.delete(point_to_delete) 244 | 245 | assert_split_creates_new_labels_for_new_clusters( 246 | incdbscan3, [points_left, points_top], CLUSTER_LABEL_FIRST_CLUSTER) 247 | 248 | assert_cluster_labels(incdbscan3, points_bottom, CLUSTER_LABEL_NOISE) 249 | 250 | 251 | def test_three_way_split( 252 | incdbscan3, 253 | point_at_origin, 254 | three_points_on_the_left, 255 | three_points_on_the_top, 256 | three_points_at_the_bottom): 257 | 258 | point_to_delete = point_at_origin 259 | points_left = three_points_on_the_left 260 | points_top = three_points_on_the_top 261 | points_bottom = three_points_at_the_bottom 262 | 263 | all_points = np.vstack([ 264 | point_to_delete, 265 | points_left, 266 | points_top, 267 | points_bottom 268 | ]) 269 | 270 | insert_objects_then_assert_cluster_labels( 271 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 272 | 273 | incdbscan3.delete(point_to_delete) 274 | 275 | assert_split_creates_new_labels_for_new_clusters( 276 | incdbscan3, 277 | [points_left, points_top, points_bottom], 278 | CLUSTER_LABEL_FIRST_CLUSTER 279 | ) 280 | 281 | 282 | def test_simultaneous_split_and_non_split( 283 | incdbscan3, 284 | point_at_origin, 285 | three_points_on_the_left): 286 | 287 | point_to_delete = point_at_origin 288 | points_left = three_points_on_the_left 289 | 290 | points_right = np.array([ 291 | [0, EPS], 292 | [0, -EPS], 293 | [EPS, 0], 294 | [EPS, EPS], 295 | [EPS, -EPS], 296 | ]) 297 | 298 | all_points = np.vstack([point_to_delete, points_left, points_right]) 299 | 300 | insert_objects_then_assert_cluster_labels( 301 | incdbscan3, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 302 | 303 | incdbscan3.delete(point_to_delete) 304 | 305 | assert_split_creates_new_labels_for_new_clusters( 306 | incdbscan3, [points_left, points_right], CLUSTER_LABEL_FIRST_CLUSTER) 307 | 308 | 309 | def test_two_way_split_with_non_dense_bridge(incdbscan4, point_at_origin): 310 | point_to_delete = bridge_point = point_at_origin 311 | 312 | points_left = np.array([ 313 | [0, -EPS], 314 | [0, -EPS * 2], 315 | [0, -EPS * 2], 316 | [0, -EPS * 3], 317 | [0, -EPS * 3], 318 | ]) 319 | 320 | points_right = np.array([ 321 | [0, EPS], 322 | [0, EPS * 2], 323 | [0, EPS * 2], 324 | [0, EPS * 3], 325 | [0, EPS * 3], 326 | ]) 327 | 328 | all_points = np.vstack([ 329 | bridge_point, point_to_delete, points_left, points_right 330 | ]) 331 | 332 | insert_objects_then_assert_cluster_labels( 333 | incdbscan4, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 334 | 335 | incdbscan4.delete(point_to_delete) 336 | 337 | assert_split_creates_new_labels_for_new_clusters( 338 | incdbscan4, [points_left, points_right], CLUSTER_LABEL_FIRST_CLUSTER) 339 | 340 | assert_label_of_object_is_among_possible_ones( 341 | incdbscan4, 342 | bridge_point, 343 | {CLUSTER_LABEL_FIRST_CLUSTER, CLUSTER_LABEL_FIRST_CLUSTER + 1} 344 | ) 345 | 346 | 347 | def test_simultaneous_splits_within_two_clusters( 348 | incdbscan4, 349 | point_at_origin, 350 | hourglass_on_the_right): 351 | 352 | point_to_delete = point_at_origin 353 | points_right = hourglass_on_the_right 354 | points_left = reflect_horizontally(points_right) 355 | 356 | incdbscan4.insert(point_to_delete) 357 | 358 | cluster_1_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 359 | insert_objects_then_assert_cluster_labels( 360 | incdbscan4, points_left, cluster_1_expected_label) 361 | 362 | cluster_2_expected_label = CLUSTER_LABEL_FIRST_CLUSTER + 1 363 | insert_objects_then_assert_cluster_labels( 364 | incdbscan4, points_right, cluster_2_expected_label) 365 | 366 | incdbscan4.delete(point_to_delete) 367 | 368 | expected_clusters = [ 369 | points_left[:3], points_left[-3:], points_right[:3], points_right[-3:] 370 | ] 371 | 372 | assert_split_creates_new_labels_for_new_clusters( 373 | incdbscan4, expected_clusters, CLUSTER_LABEL_FIRST_CLUSTER) 374 | 375 | expected_cluster_labels_left = { 376 | incdbscan4.get_cluster_labels(points_left[[2]])[0], 377 | incdbscan4.get_cluster_labels(points_left[[4]])[0], 378 | } 379 | 380 | assert_label_of_object_is_among_possible_ones( 381 | incdbscan4, points_left[[3]], expected_cluster_labels_left) 382 | 383 | expected_cluster_labels_right = { 384 | incdbscan4.get_cluster_labels(points_right[[2]])[0], 385 | incdbscan4.get_cluster_labels(points_right[[4]])[0] 386 | } 387 | 388 | assert_label_of_object_is_among_possible_ones( 389 | incdbscan4, points_right[[3]], expected_cluster_labels_right) 390 | 391 | 392 | def test_two_non_dense_bridges(incdbscan4, point_at_origin): 393 | point_to_delete = point_at_origin 394 | 395 | points_left = np.array([ 396 | [-EPS, 0], 397 | [-EPS, 0], 398 | [-EPS, -EPS], 399 | [-EPS, -EPS], 400 | [-EPS, -EPS * 2], 401 | ]) 402 | points_right = reflect_horizontally(points_left) 403 | 404 | points_top = np.array([ 405 | [0, EPS], 406 | [0, EPS], 407 | [0, EPS * 2], 408 | [0, EPS * 2], 409 | [0, EPS * 3], 410 | [0, EPS * 3], 411 | [0, EPS * 4], 412 | [0, EPS * 4], 413 | ]) 414 | 415 | bottom_bridge = np.array([[0, -EPS * 2]]) 416 | 417 | all_points = np.vstack([ 418 | point_to_delete, points_left, points_right, points_top, bottom_bridge 419 | ]) 420 | 421 | insert_objects_then_assert_cluster_labels( 422 | incdbscan4, all_points, CLUSTER_LABEL_FIRST_CLUSTER) 423 | 424 | incdbscan4.delete(point_to_delete) 425 | 426 | expected_clusters = [points_left, points_right, points_top] 427 | 428 | assert_split_creates_new_labels_for_new_clusters( 429 | incdbscan4, expected_clusters, CLUSTER_LABEL_FIRST_CLUSTER) 430 | -------------------------------------------------------------------------------- /incdbscan/tests/test_incrementaldbscan.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from incdbscan import ( 4 | IncrementalDBSCAN, 5 | IncrementalDBSCANWarning 6 | ) 7 | from testutils import ( 8 | CLUSTER_LABEL_NOISE, 9 | delete_object_and_assert_error, 10 | delete_object_and_assert_no_warning, 11 | delete_object_and_assert_warning, 12 | get_label_and_assert_error, 13 | get_label_and_assert_no_warning, 14 | get_label_and_assert_warning, 15 | insert_object_and_assert_error, 16 | insert_objects_then_assert_cluster_labels 17 | ) 18 | 19 | 20 | def test_error_when_input_is_non_numeric(incdbscan3): 21 | inputs_not_welcomed = np.array([ 22 | [1, 2, 'x'], 23 | [1, 2, None], 24 | [1, 2, np.nan], 25 | [1, 2, np.inf], 26 | ]) 27 | 28 | for i in range(len(inputs_not_welcomed)): 29 | input_ = inputs_not_welcomed[[i]] 30 | 31 | insert_object_and_assert_error(incdbscan3, input_, ValueError) 32 | delete_object_and_assert_error(incdbscan3, input_, ValueError) 33 | get_label_and_assert_error(incdbscan3, input_, ValueError) 34 | 35 | 36 | def test_handling_of_same_object_with_different_dtype(incdbscan3): 37 | object_as_int = np.array([[1, 2]]) 38 | object_as_float = np.array([[1., 2.]]) 39 | 40 | incdbscan3.insert(object_as_int) 41 | 42 | assert incdbscan3.get_cluster_labels(object_as_int) == \ 43 | incdbscan3.get_cluster_labels(object_as_float) 44 | 45 | delete_object_and_assert_no_warning(incdbscan3, object_as_float) 46 | 47 | 48 | def test_handling_of_more_than_2d_arrays(incdbscan3, incdbscan4): 49 | object_3d = np.array([[1, 2, 3]]) 50 | 51 | incdbscan3.insert(object_3d) 52 | incdbscan3.insert(object_3d) 53 | incdbscan3.delete(object_3d) 54 | 55 | assert incdbscan3.get_cluster_labels(object_3d) == CLUSTER_LABEL_NOISE 56 | 57 | object_100d = np.random.random(100).reshape(1, -1) 58 | 59 | incdbscan4.insert(object_100d) 60 | incdbscan4.insert(object_100d) 61 | incdbscan4.delete(object_100d) 62 | 63 | assert incdbscan4.get_cluster_labels(object_100d) == CLUSTER_LABEL_NOISE 64 | 65 | 66 | def test_no_warning_when_a_known_object_is_deleted( 67 | incdbscan3, 68 | point_at_origin): 69 | 70 | incdbscan3.insert(point_at_origin) 71 | delete_object_and_assert_no_warning(incdbscan3, point_at_origin) 72 | 73 | incdbscan3.insert(point_at_origin) 74 | incdbscan3.insert(point_at_origin) 75 | delete_object_and_assert_no_warning(incdbscan3, point_at_origin) 76 | delete_object_and_assert_no_warning(incdbscan3, point_at_origin) 77 | 78 | 79 | def test_warning_when_unknown_object_is_deleted( 80 | incdbscan3, 81 | point_at_origin): 82 | 83 | delete_object_and_assert_warning( 84 | incdbscan3, point_at_origin, IncrementalDBSCANWarning) 85 | 86 | incdbscan3.insert(point_at_origin) 87 | 88 | incdbscan3.delete(point_at_origin) 89 | 90 | delete_object_and_assert_warning( 91 | incdbscan3, point_at_origin, IncrementalDBSCANWarning) 92 | 93 | 94 | def test_no_warning_when_cluster_label_is_gotten_for_known_object( 95 | incdbscan3, 96 | point_at_origin): 97 | 98 | expected_label = np.array([CLUSTER_LABEL_NOISE]) 99 | 100 | incdbscan3.insert(point_at_origin) 101 | label = get_label_and_assert_no_warning(incdbscan3, point_at_origin) 102 | assert label == expected_label 103 | 104 | incdbscan3.insert(point_at_origin) 105 | incdbscan3.delete(point_at_origin) 106 | label = get_label_and_assert_no_warning(incdbscan3, point_at_origin) 107 | assert label == expected_label 108 | 109 | 110 | def test_warning_when_cluster_label_is_gotten_for_unknown_object( 111 | incdbscan3, 112 | point_at_origin): 113 | 114 | label = get_label_and_assert_warning( 115 | incdbscan3, point_at_origin, IncrementalDBSCANWarning) 116 | assert np.isnan(label) 117 | 118 | incdbscan3.insert(point_at_origin) 119 | incdbscan3.delete(point_at_origin) 120 | 121 | label = get_label_and_assert_warning( 122 | incdbscan3, point_at_origin, IncrementalDBSCANWarning) 123 | assert np.isnan(label) 124 | 125 | 126 | def test_different_metrics_are_available(): 127 | incdbscan_euclidean = \ 128 | IncrementalDBSCAN(eps=1.5, min_pts=3, metric='euclidean') 129 | incdbscan_manhattan = \ 130 | IncrementalDBSCAN(eps=1.5, min_pts=3, metric='manhattan') 131 | 132 | diagonal = np.array([ 133 | [0, 0], 134 | [1, 1], 135 | [2, 2], 136 | ]) 137 | 138 | expected_label_euclidean = CLUSTER_LABEL_NOISE + 1 139 | insert_objects_then_assert_cluster_labels( 140 | incdbscan_euclidean, diagonal, expected_label_euclidean) 141 | 142 | expected_label_manhattan = CLUSTER_LABEL_NOISE 143 | insert_objects_then_assert_cluster_labels( 144 | incdbscan_manhattan, diagonal, expected_label_manhattan) 145 | -------------------------------------------------------------------------------- /incdbscan/tests/test_inserter.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from conftest import EPS 3 | 4 | from testutils import ( 5 | CLUSTER_LABEL_FIRST_CLUSTER, 6 | CLUSTER_LABEL_NOISE, 7 | assert_cluster_labels, 8 | assert_label_of_object_is_among_possible_ones, 9 | assert_two_objects_are_in_same_cluster, 10 | insert_objects_then_assert_cluster_labels, 11 | reflect_horizontally 12 | ) 13 | 14 | 15 | def test_new_single_object_is_labeled_as_noise(incdbscan4, object_far_away): 16 | incdbscan4.insert(object_far_away) 17 | assert_cluster_labels(incdbscan4, object_far_away, CLUSTER_LABEL_NOISE) 18 | 19 | 20 | def test_new_object_far_from_cluster_is_labeled_as_noise( 21 | incdbscan4, 22 | blob_in_middle, 23 | object_far_away): 24 | 25 | incdbscan4.insert(blob_in_middle) 26 | incdbscan4.insert(object_far_away) 27 | 28 | assert_cluster_labels(incdbscan4, object_far_away, CLUSTER_LABEL_NOISE) 29 | 30 | 31 | def test_new_border_object_gets_label_from_core(incdbscan4): 32 | cluster = np.array([ 33 | [1., 1.], 34 | [0., 1.], 35 | [1., 0.], 36 | [0., 0.], 37 | ]) 38 | 39 | new_border_object = np.array([[1 + EPS, 1]]) 40 | 41 | incdbscan4.insert(cluster) 42 | incdbscan4.insert(new_border_object) 43 | 44 | print(incdbscan4.get_cluster_labels(cluster[[0]])) 45 | print(incdbscan4.get_cluster_labels(new_border_object)) 46 | 47 | assert_two_objects_are_in_same_cluster( 48 | incdbscan4, cluster[[0]], new_border_object) 49 | 50 | 51 | def test_labels_are_noise_only_until_not_enough_objects_in_cluster( 52 | incdbscan4, 53 | blob_in_middle): 54 | 55 | for i in range(len(blob_in_middle)): 56 | incdbscan4.insert(blob_in_middle[[i]]) 57 | 58 | expected_label = ( 59 | CLUSTER_LABEL_NOISE if i + 1 < incdbscan4.min_pts 60 | else CLUSTER_LABEL_FIRST_CLUSTER 61 | ) 62 | 63 | assert_cluster_labels(incdbscan4, blob_in_middle[:i+1], expected_label) 64 | 65 | 66 | def test_more_than_two_clusters_can_be_created(incdbscan4, blob_in_middle): 67 | cluster_1 = blob_in_middle 68 | cluster_1_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 69 | 70 | insert_objects_then_assert_cluster_labels( 71 | incdbscan4, cluster_1, cluster_1_expected_label) 72 | 73 | cluster_2 = cluster_1 + 10 74 | cluster_2_expected_label = cluster_1_expected_label + 1 75 | 76 | insert_objects_then_assert_cluster_labels( 77 | incdbscan4, cluster_2, cluster_2_expected_label) 78 | 79 | cluster_3 = cluster_2 + 10 80 | cluster_3_expected_label = cluster_2_expected_label + 1 81 | 82 | insert_objects_then_assert_cluster_labels( 83 | incdbscan4, cluster_3, cluster_3_expected_label) 84 | 85 | 86 | def test_two_clusters_can_be_born_at_the_same_time( 87 | incdbscan4, 88 | point_at_origin): 89 | 90 | cluster_1 = np.array([ 91 | [EPS * 1, 0], 92 | [EPS * 2, 0], 93 | [EPS * 2, 0], 94 | ]) 95 | 96 | cluster_2 = reflect_horizontally(cluster_1) 97 | 98 | incdbscan4.insert(cluster_1) 99 | incdbscan4.insert(cluster_2) 100 | 101 | assert_cluster_labels(incdbscan4, cluster_1, CLUSTER_LABEL_NOISE) 102 | assert_cluster_labels(incdbscan4, cluster_2, CLUSTER_LABEL_NOISE) 103 | 104 | new_object = point_at_origin 105 | incdbscan4.insert(new_object) 106 | 107 | cluster_1_label_expected = incdbscan4.get_cluster_labels(cluster_1[[0]])[0] 108 | assert_cluster_labels(incdbscan4, cluster_1, cluster_1_label_expected) 109 | 110 | cluster_2_label_expected = \ 111 | CLUSTER_LABEL_FIRST_CLUSTER + 1 - cluster_1_label_expected 112 | assert_cluster_labels(incdbscan4, cluster_2, cluster_2_label_expected) 113 | 114 | assert_label_of_object_is_among_possible_ones( 115 | incdbscan4, 116 | new_object, 117 | {cluster_1_label_expected, cluster_2_label_expected} 118 | ) 119 | 120 | 121 | def test_absorption_with_noise(incdbscan3, point_at_origin): 122 | expected_cluster_label = CLUSTER_LABEL_FIRST_CLUSTER 123 | 124 | cluster_values = np.array([ 125 | [EPS, 0], 126 | [EPS * 2, 0], 127 | [EPS * 3, 0], 128 | ]) 129 | 130 | insert_objects_then_assert_cluster_labels( 131 | incdbscan3, cluster_values, expected_cluster_label) 132 | 133 | noise = np.array([[0, EPS]]) 134 | 135 | insert_objects_then_assert_cluster_labels( 136 | incdbscan3, noise, CLUSTER_LABEL_NOISE) 137 | 138 | new_object_value = point_at_origin 139 | 140 | insert_objects_then_assert_cluster_labels( 141 | incdbscan3, new_object_value, expected_cluster_label) 142 | 143 | assert_cluster_labels(incdbscan3, noise, expected_cluster_label) 144 | 145 | 146 | def test_merge_two_clusters(incdbscan3, point_at_origin): 147 | cluster_1 = np.array([ 148 | [EPS, 0], 149 | [EPS * 2, 0], 150 | [EPS * 3, 0], 151 | [EPS * 4, 0], 152 | ]) 153 | cluster_1_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 154 | 155 | insert_objects_then_assert_cluster_labels( 156 | incdbscan3, cluster_1, cluster_1_expected_label) 157 | 158 | cluster_2 = reflect_horizontally(cluster_1) 159 | cluster_2_expected_label = cluster_1_expected_label + 1 160 | 161 | insert_objects_then_assert_cluster_labels( 162 | incdbscan3, cluster_2, cluster_2_expected_label) 163 | 164 | new_object = point_at_origin 165 | merged_cluster_expected_label = \ 166 | max([cluster_1_expected_label, cluster_2_expected_label]) 167 | 168 | insert_objects_then_assert_cluster_labels( 169 | incdbscan3, new_object, merged_cluster_expected_label) 170 | 171 | assert_cluster_labels(incdbscan3, cluster_1, merged_cluster_expected_label) 172 | assert_cluster_labels(incdbscan3, cluster_2, merged_cluster_expected_label) 173 | 174 | 175 | def test_merger_and_creation_can_happen_at_the_same_time( 176 | incdbscan4, 177 | point_at_origin, 178 | hourglass_on_the_right): 179 | 180 | # Insert objects to the right 181 | hourglass = hourglass_on_the_right 182 | 183 | top_right = hourglass[:3] 184 | top_right_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 185 | 186 | bottom_right = hourglass[-3:] 187 | bottom_right_expected_label = top_right_expected_label + 1 188 | 189 | bridge_point = hourglass[[3]] 190 | 191 | incdbscan4.insert(top_right) 192 | incdbscan4.insert(bridge_point) 193 | incdbscan4.insert(bottom_right) 194 | 195 | assert_cluster_labels(incdbscan4, top_right, top_right_expected_label) 196 | assert_cluster_labels( 197 | incdbscan4, bottom_right, bottom_right_expected_label) 198 | 199 | assert_label_of_object_is_among_possible_ones( 200 | incdbscan4, 201 | bridge_point, 202 | {bottom_right_expected_label, bottom_right_expected_label} 203 | ) 204 | 205 | merged_cluster_expected_label = \ 206 | incdbscan4.get_cluster_labels(bridge_point)[0] 207 | 208 | # Insert objects to the left 209 | left_pre_cluster = np.array([ 210 | [-EPS, 0], 211 | [-EPS * 2, 0], 212 | [-EPS * 2, 0], 213 | ]) 214 | left_cluster_expected_label = bottom_right_expected_label + 1 215 | 216 | insert_objects_then_assert_cluster_labels( 217 | incdbscan4, 218 | left_pre_cluster, 219 | CLUSTER_LABEL_NOISE 220 | ) 221 | 222 | # Insert object to the center 223 | new_object = point_at_origin 224 | incdbscan4.insert(new_object) 225 | 226 | assert_cluster_labels( 227 | incdbscan4, top_right, merged_cluster_expected_label) 228 | assert_cluster_labels( 229 | incdbscan4, bottom_right, merged_cluster_expected_label) 230 | assert_cluster_labels( 231 | incdbscan4, bridge_point, merged_cluster_expected_label) 232 | assert_cluster_labels( 233 | incdbscan4, left_pre_cluster, left_cluster_expected_label) 234 | 235 | assert_label_of_object_is_among_possible_ones( 236 | incdbscan4, 237 | new_object, 238 | {merged_cluster_expected_label, left_cluster_expected_label} 239 | ) 240 | 241 | 242 | def test_two_mergers_can_happen_at_the_same_time( 243 | incdbscan4, 244 | point_at_origin, 245 | hourglass_on_the_right): 246 | 247 | # Insert objects to the right 248 | top_right = hourglass_on_the_right[:3] 249 | top_right_expected_label = CLUSTER_LABEL_FIRST_CLUSTER 250 | 251 | bottom_right = hourglass_on_the_right[-3:] 252 | bottom_right_expected_label = top_right_expected_label + 1 253 | 254 | bridge_point_right = hourglass_on_the_right[[3]] 255 | 256 | incdbscan4.insert(top_right) 257 | incdbscan4.insert(bridge_point_right) 258 | incdbscan4.insert(bottom_right) 259 | 260 | assert_cluster_labels(incdbscan4, top_right, top_right_expected_label) 261 | assert_cluster_labels( 262 | incdbscan4, bottom_right, bottom_right_expected_label) 263 | 264 | assert_label_of_object_is_among_possible_ones( 265 | incdbscan4, 266 | bridge_point_right, 267 | {bottom_right_expected_label, bottom_right_expected_label} 268 | ) 269 | 270 | # Insert objects to the left 271 | hourglass_on_the_left = reflect_horizontally(hourglass_on_the_right) 272 | 273 | top_left = hourglass_on_the_left[:3] 274 | top_left_expected_label = bottom_right_expected_label + 1 275 | 276 | bottom_left = hourglass_on_the_left[-3:] 277 | bottom_left_expected_label = top_left_expected_label + 1 278 | 279 | bridge_point_left = hourglass_on_the_left[[3]] 280 | 281 | incdbscan4.insert(top_left) 282 | incdbscan4.insert(bridge_point_left) 283 | incdbscan4.insert(bottom_left) 284 | 285 | assert_cluster_labels(incdbscan4, top_left, top_left_expected_label) 286 | assert_cluster_labels(incdbscan4, bottom_left, bottom_left_expected_label) 287 | 288 | assert_label_of_object_is_among_possible_ones( 289 | incdbscan4, 290 | bridge_point_left, 291 | {top_left_expected_label, bottom_left_expected_label} 292 | ) 293 | 294 | # Insert object to the center 295 | new_object = point_at_origin 296 | incdbscan4.insert(new_object) 297 | 298 | assert_cluster_labels( 299 | incdbscan4, 300 | np.vstack([top_right, bottom_right]), 301 | bottom_right_expected_label 302 | ) 303 | 304 | assert_cluster_labels( 305 | incdbscan4, 306 | np.vstack([top_left, bottom_left]), 307 | bottom_left_expected_label 308 | ) 309 | 310 | assert_label_of_object_is_among_possible_ones( 311 | incdbscan4, 312 | bridge_point_right, 313 | {bottom_left_expected_label, bottom_right_expected_label} 314 | ) 315 | 316 | assert_label_of_object_is_among_possible_ones( 317 | incdbscan4, 318 | bridge_point_left, 319 | {top_left_expected_label, bottom_left_expected_label} 320 | ) 321 | 322 | 323 | def test_object_is_core_if_it_has_more_than_enough_neigbhors( 324 | incdbscan3, 325 | point_at_origin): 326 | 327 | neighbors = np.array([ 328 | [0, EPS], 329 | [0, -EPS], 330 | [EPS, 0], 331 | [-EPS, 0], 332 | ]) 333 | expected_label = CLUSTER_LABEL_FIRST_CLUSTER 334 | 335 | incdbscan3.insert(neighbors) 336 | incdbscan3.insert(point_at_origin) 337 | 338 | assert_cluster_labels(incdbscan3, neighbors, expected_label) 339 | assert_cluster_labels(incdbscan3, point_at_origin, expected_label) 340 | -------------------------------------------------------------------------------- /incdbscan/tests/test_testutils.py: -------------------------------------------------------------------------------- 1 | from testutils import are_lists_isomorphic 2 | 3 | 4 | def test_isomorphism_check_fails_when_different_length(): 5 | assert not are_lists_isomorphic([0, 0], [0, 0, 0]) 6 | 7 | 8 | def test_isomorphism_check_fails_when_different_size_of_value_sets(): 9 | assert not are_lists_isomorphic([0, 0], [0, 1]) 10 | 11 | 12 | def test_isomorphism_check_fails_when_there_is_no_isomorphism(): 13 | assert not are_lists_isomorphic([1, 2, 3, 1], [1, 1, 3, 2]) 14 | 15 | 16 | def test_isomorphism_check_succeeds_when_there_is_isomorphism(): 17 | assert are_lists_isomorphic([1, 1, 2, 2, 3], [2, 2, 3, 3, 4]) 18 | -------------------------------------------------------------------------------- /incdbscan/tests/test_with_data.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from sklearn.cluster import DBSCAN 4 | 5 | from incdbscan import IncrementalDBSCAN 6 | from testutils import ( 7 | are_lists_isomorphic, 8 | read_handl_data 9 | ) 10 | 11 | 12 | @pytest.mark.slow 13 | def test_same_results_as_sklearn_dbscan(): 14 | EPS = 1 15 | MIN_PTS = 5 16 | 17 | data = read_handl_data() 18 | dbscan = DBSCAN(eps=EPS, min_samples=MIN_PTS) 19 | labels_dbscan = dbscan.fit_predict(data) 20 | 21 | incdbscan = IncrementalDBSCAN(eps=EPS, min_pts=MIN_PTS) 22 | labels_incdbscan_1 = incdbscan.insert(data).get_cluster_labels(data) 23 | assert are_lists_isomorphic(labels_dbscan, labels_incdbscan_1) 24 | 25 | labels_incdbscan_2 = \ 26 | incdbscan.insert(data).delete(data).get_cluster_labels(data) 27 | assert are_lists_isomorphic(labels_dbscan, labels_incdbscan_2) 28 | 29 | np.random.seed(123) 30 | noise = np.random.uniform(-14, 14, (1000, 2)) 31 | labels_incdbscan_3 = \ 32 | incdbscan.insert(noise).delete(noise).get_cluster_labels(data) 33 | assert are_lists_isomorphic(labels_dbscan, labels_incdbscan_3) 34 | -------------------------------------------------------------------------------- /incdbscan/tests/testutils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from io import StringIO 3 | from typing import Iterable 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import pytest 8 | import requests 9 | 10 | 11 | CLUSTER_LABEL_NOISE = -1 12 | CLUSTER_LABEL_FIRST_CLUSTER = 0 13 | 14 | 15 | def assert_cluster_labels(incdbscan_fit, objects: Iterable, label): 16 | assert np.all( 17 | incdbscan_fit.get_cluster_labels(objects) == label 18 | ) 19 | 20 | 21 | def assert_two_objects_are_in_same_cluster(incdbscan_fit, object1, object2): 22 | assert incdbscan_fit.get_cluster_labels(object1) == \ 23 | incdbscan_fit.get_cluster_labels(object2) 24 | 25 | 26 | def assert_label_of_object_is_among_possible_ones( 27 | incdbscan_fit, 28 | obj, 29 | possible_labels): 30 | 31 | assert incdbscan_fit.get_cluster_labels(obj)[0] in possible_labels 32 | 33 | 34 | def insert_objects_then_assert_cluster_labels( 35 | incdbscan, 36 | values: Iterable, 37 | expected_label): 38 | 39 | incdbscan.insert(values) 40 | assert_cluster_labels(incdbscan, values, expected_label) 41 | 42 | 43 | def assert_split_creates_new_labels_for_new_clusters( 44 | incdbscan_fit, 45 | clusters: Iterable[Iterable], 46 | previous_common_label): 47 | 48 | all_labels = set() 49 | 50 | for cluster in clusters: 51 | labels_within_cluster = set() 52 | 53 | for obj in cluster: 54 | label_of_object = incdbscan_fit.get_cluster_labels([obj])[0] 55 | labels_within_cluster.add(label_of_object) 56 | 57 | assert len(labels_within_cluster) == 1 58 | all_labels.update(labels_within_cluster) 59 | 60 | assert previous_common_label in all_labels 61 | assert len(all_labels) == len(clusters) 62 | assert CLUSTER_LABEL_NOISE not in all_labels 63 | 64 | 65 | def reflect_horizontally(points): 66 | new_points = np.copy(points) 67 | new_points[:, 0] = np.negative(new_points[:, 0]) 68 | return new_points 69 | 70 | 71 | def delete_object_and_assert_error(incdbscan_fit, obj, error): 72 | with pytest.raises(error): 73 | incdbscan_fit.delete(obj) 74 | 75 | 76 | def delete_object_and_assert_no_warning(incdbscan_fit, obj): 77 | with warnings.catch_warnings(): 78 | warnings.simplefilter("error") 79 | incdbscan_fit.delete(obj) 80 | 81 | 82 | def delete_object_and_assert_warning(incdbscan_fit, obj, warning): 83 | with pytest.warns(warning): 84 | incdbscan_fit.delete(obj) 85 | 86 | 87 | def get_label_and_assert_error(incdbscan_fit, obj, error): 88 | with pytest.raises(error): 89 | incdbscan_fit.get_cluster_labels(obj) 90 | 91 | 92 | def get_label_and_assert_no_warning(incdbscan_fit, obj): 93 | with warnings.catch_warnings(): 94 | warnings.simplefilter("error") 95 | incdbscan_fit.get_cluster_labels(obj) 96 | 97 | return incdbscan_fit.get_cluster_labels(obj) 98 | 99 | 100 | def get_label_and_assert_warning(incdbscan_fit, obj, warning): 101 | with pytest.warns(warning): 102 | return incdbscan_fit.get_cluster_labels(obj) 103 | 104 | 105 | def insert_object_and_assert_error(incdbscan_fit, obj, error): 106 | with pytest.raises(error): 107 | incdbscan_fit.insert(obj) 108 | 109 | 110 | def are_lists_isomorphic(list_1, list_2): 111 | if len(list_1) != len(list_2): 112 | return False 113 | 114 | distinct_elements_1 = set(list_1) 115 | distinct_elements_2 = set(list_2) 116 | 117 | if len(distinct_elements_1) != len(distinct_elements_2): 118 | return False 119 | 120 | mappings = list(zip(list_1, list_2)) 121 | distinct_mappings = set(mappings) 122 | 123 | return len(distinct_elements_1) == len(distinct_mappings) 124 | 125 | 126 | def read_text_data_file_from_url(url): 127 | content = requests.get(url) 128 | data = np.loadtxt(StringIO(content.text)) 129 | return data 130 | 131 | 132 | def read_arff_data_file_from_url(url): 133 | content = requests.get(url) 134 | data = pd.read_csv(StringIO(content.text), skiprows=10) 135 | data = data.to_numpy() 136 | return data 137 | 138 | 139 | def read_handl_data(): 140 | # This is equivalent to the 2d-20c-no0 data set by Handl, J. 141 | # Also available from: 142 | # https://personalpages.manchester.ac.uk/staff/Julia.Handl/generators.html 143 | 144 | url = ( 145 | 'https://raw.githubusercontent.com/deric/clustering-benchmark/' 146 | 'master/src/main/resources/datasets/artificial/2d-20c-no0.arff' 147 | ) 148 | data = read_arff_data_file_from_url(url)[:, 0:2] 149 | return data 150 | 151 | 152 | def read_chameleon_data(): 153 | # This is equivalent to the t4.8k data set from the Chameleon collection 154 | # by Karypis, G. et al. Also available from: 155 | # http://glaros.dtc.umn.edu/gkhome/cluto/cluto/download 156 | 157 | url = ( 158 | 'https://raw.githubusercontent.com/yeahia2508/ml-examples/' 159 | 'master/Data/clustering/chameleon/t4.8k.txt' 160 | ) 161 | data = read_text_data_file_from_url(url)[:2000] 162 | return data 163 | -------------------------------------------------------------------------------- /notebooks/incdbscan-usage.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# `incdbscan` meets Iris" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "This is an introduction to the API of the `incdbscan` package. The logic of DBSCAN and its hyperparameters will not be covered here. Those can be checked at scikit-learn's [documentation about DBSCAN](https://scikit-learn.org/stable/modules/clustering.html#dbscan)." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": 1, 20 | "metadata": {}, 21 | "outputs": [ 22 | { 23 | "name": "stdout", 24 | "output_type": "stream", 25 | "text": [ 26 | "/home/xyz\n" 27 | ] 28 | } 29 | ], 30 | "source": [ 31 | "cd .." 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": 2, 37 | "metadata": {}, 38 | "outputs": [], 39 | "source": [ 40 | "%matplotlib inline\n", 41 | "import matplotlib.pyplot as plt\n", 42 | "from sklearn.datasets import load_iris\n", 43 | "\n", 44 | "def plot_iris(array, labels=None):\n", 45 | " colors = ['blue', 'green', 'yellow', 'black', 'orange', 'purple', 'red', 'silver'] # quite hacky...\n", 46 | " plt.figure(figsize=(10, 10))\n", 47 | " \n", 48 | " if labels is not None:\n", 49 | " for label in set(labels):\n", 50 | " rows = label == labels\n", 51 | " plt.scatter(array[rows, 0], array[rows, 1], c=colors[int(label)], label=str(int(label)))\n", 52 | " plt.legend(title='Cluster labels')\n", 53 | " else:\n", 54 | " plt.scatter(array[:, 0], array[:, 1], c=labels, cmap='Set1')\n", 55 | " \n", 56 | " plt.xlim([4.0, 8.5])\n", 57 | " plt.ylim([1.75, 4.5])\n", 58 | " plt.xlabel('sepal length (cm)')\n", 59 | " plt.ylabel('sepal width (cm)')\n", 60 | " plt.show()\n" 61 | ] 62 | }, 63 | { 64 | "cell_type": "markdown", 65 | "metadata": {}, 66 | "source": [ 67 | "The data set we are going to cluster is 2-D variant of the Iris dataset (although `incdbscan` is capable of dealing with higher dimensions). The data set is split into two batches of points. We take a peek to see all data points first, then get to clustering." 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 3, 73 | "metadata": {}, 74 | "outputs": [ 75 | { 76 | "name": "stderr", 77 | "output_type": "stream", 78 | "text": [ 79 | "/tmp/ipykernel_64753/3386316966.py:15: UserWarning: No data for colormapping provided via 'c'. Parameters 'cmap' will be ignored\n", 80 | " plt.scatter(array[:, 0], array[:, 1], c=labels, cmap='Set1')\n" 81 | ] 82 | }, 83 | { 84 | "data": { 85 | "image/png": "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\n", 86 | "text/plain": [ 87 | "
" 88 | ] 89 | }, 90 | "metadata": { 91 | "needs_background": "light" 92 | }, 93 | "output_type": "display_data" 94 | } 95 | ], 96 | "source": [ 97 | "X = load_iris()['data'][:, :2]\n", 98 | "X_1, X_2 = X[:75], X[75:]\n", 99 | "\n", 100 | "plot_iris(X)" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": 4, 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "from incdbscan import IncrementalDBSCAN\n", 110 | "clusterer = IncrementalDBSCAN(eps=0.25, min_pts=5)" 111 | ] 112 | }, 113 | { 114 | "cell_type": "markdown", 115 | "metadata": {}, 116 | "source": [ 117 | "## Input data shape\n", 118 | "\n", 119 | "All 3 main methods - `insert`, `delete` and `get_cluster_labels` - expect data points to be in a 2-D `ndarray` of shape `(n_samples, n_features)`, but also can be in a pandas `DataFrame` or in a list of data points stored in 1-D `ndarray`s of shape `(n_features,)`." 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": {}, 125 | "source": [ 126 | "## Insert data points with `insert`\n", 127 | "First insert a batch of points into the clusterer. Note that `insert` returns with the `IncrementalDBSCAN` instance itself." 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": 5, 133 | "metadata": {}, 134 | "outputs": [ 135 | { 136 | "data": { 137 | "text/plain": [ 138 | "" 139 | ] 140 | }, 141 | "execution_count": 5, 142 | "metadata": {}, 143 | "output_type": "execute_result" 144 | } 145 | ], 146 | "source": [ 147 | "clusterer.insert(X_1)" 148 | ] 149 | }, 150 | { 151 | "cell_type": "markdown", 152 | "metadata": {}, 153 | "source": [ 154 | "The same points can be inserted again if wanted. Let's insert the first 10 data points again into the clusterer. Now these points will be there two times." 155 | ] 156 | }, 157 | { 158 | "cell_type": "code", 159 | "execution_count": 6, 160 | "metadata": {}, 161 | "outputs": [ 162 | { 163 | "data": { 164 | "text/plain": [ 165 | "" 166 | ] 167 | }, 168 | "execution_count": 6, 169 | "metadata": {}, 170 | "output_type": "execute_result" 171 | } 172 | ], 173 | "source": [ 174 | "clusterer.insert(X_1[:10])" 175 | ] 176 | }, 177 | { 178 | "cell_type": "markdown", 179 | "metadata": {}, 180 | "source": [ 181 | "## Obtain cluster labels with `get_cluster_labels`\n", 182 | "This method helps us gain the cluster labels. In contrast with scikit-learn's `DBSCAN`, the labels are obtained not for all objects but for objects we specifically ask cluster labels for. Let's get the cluster labels for the whole 1st batch and see them on a plot!" 183 | ] 184 | }, 185 | { 186 | "cell_type": "code", 187 | "execution_count": 7, 188 | "metadata": {}, 189 | "outputs": [], 190 | "source": [ 191 | "labels_1 = clusterer.get_cluster_labels(X_1)" 192 | ] 193 | }, 194 | { 195 | "cell_type": "code", 196 | "execution_count": 8, 197 | "metadata": {}, 198 | "outputs": [ 199 | { 200 | "data": { 201 | "text/plain": [ 202 | "array([ 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,\n", 203 | " 1., -1., -1., 1., 1., -1., 1., 1., 1., 1., 1., 1., 1.,\n", 204 | " 1., 1., 1., 1., 1., 1., -1., -1., 1., 1., 1., 1., 1.,\n", 205 | " 1., 1., -1., 1., 1., 1., 1., 1., 1., 1., 1., -1., -1.,\n", 206 | " -1., -1., -1., -1., -1., -1., -1., -1., -1., -1., -1., -1., -1.,\n", 207 | " -1., -1., -1., -1., -1., -1., -1., -1., -1., -1.])" 208 | ] 209 | }, 210 | "execution_count": 8, 211 | "metadata": {}, 212 | "output_type": "execute_result" 213 | } 214 | ], 215 | "source": [ 216 | "labels_1" 217 | ] 218 | }, 219 | { 220 | "cell_type": "code", 221 | "execution_count": 9, 222 | "metadata": {}, 223 | "outputs": [ 224 | { 225 | "data": { 226 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmkAAAJRCAYAAAAeWCASAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAAsTAAALEwEAmpwYAAA5EElEQVR4nO3dfXycdZ3v//dnkuyUCAbFOgI1ybVqXYVioYPaRREo3qzlRh/qgT1dd+vNZqWKcvbs7llPjwoc8+On4lpdBY3AEdesy8rxhtJdF60ieo6ICRZSRAZ0SC3WoaJGsHDZZj7nj0xikibNTDrXXN/MvJ6PRx7N9Z3r5vO9Ltp5c13X97rM3QUAAICwZNIuAAAAAAcjpAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEKPGQZmZtZvYDM7t5js82mtleM9tR+Xlr0vUAAAAsBe0N2Ma7JN0r6cnzfH6Du7+jAXUAAAAsGYmeSTOzFZLWS7omye0AAAA0m6Qvd26R9HeSyoeY53VmdreZ3Whmz0y4HgAAgCUhscudZnaOpIfdfdjMzphntq2SPu/usZn9laTrJZ01x7r6JPVJ0pOe9KQ1f/RHf5RM0QAAAHU0PDz8C3dfvphlLal3d5rZFZLeKOmApGWauCfti+7+Z/PM3ybpl+7edaj15vN5Hxoaqne5AAAAdWdmw+6eX8yyiV3udPd3u/sKd++VdKGkb8wOaGZ27LTJ8zQxwAAAAKDlNWJ05wxmdrmkIXe/SdI7zew8TZxt+6WkjY2uBwAAIESJXe5MCpc7AQDAUnE4lzsbfiYNAAAsDfv379fu3bv1xBNPpF1K8JYtW6YVK1aoo6OjbuskpAEAgDnt3r1bRx11lHp7e2VmaZcTLHfXI488ot27dyuKorqtl3d3AgCAOT3xxBM65phjCGgLMDMdc8wxdT/jSEgDAADzIqBVJ4n9REgDAABV+/nPf64LL7xQz3rWs7RmzRq9+tWvVqFQ0IMPPqgTTzxxUev8zGc+o5/97GeHVddnPvMZveMdh34V+KWXXqorr7yypvUeeeSRh1PWYSGkAQCAqri7Xvva1+qMM87Qj3/8Yw0PD+uKK65QqVQ6rPUuJqQdOHDgsLa5FBDSAABAVb75zW+qo6NDb3vb26baXvCCF+ilL33pjPlmn9U655xzdOutt2p8fFwbN27UiSeeqFWrVukjH/mIbrzxRg0NDWnDhg1avXq1Hn/8cQ0PD+tlL3uZ1qxZo1e+8pXas2ePJOmMM87QJZdconw+r49+9KPz1rl161a96EUv0sknn6yzzz57Roi86667tHbtWj3nOc/Rpz/96an2D33oQzr11FN10kkn6X3ve99B69yzZ49OP/10rV69WieeeKK+/e1v174Da8ToTgAAUJWdO3dqzZo1i15+x44deuihh7Rz505J0q9//WsdffTR+vjHP64rr7xS+Xxe+/fv18UXX6yvfOUrWr58uW644QZt3rxZ1113nSTpd7/7nRZ6XupLXvIS3X777TIzXXPNNfrgBz+oD3/4w5Kku+++W7fffrt++9vf6uSTT9b69eu1c+dO3X///brjjjvk7jrvvPN022236fTTT59a5z//8z/rla98pTZv3qzx8XHt27dv0fuhWoQ0AADQEH/4h3+on/zkJ7r44ou1fv16veIVrzhonvvuu087d+7Uy1/+cknS+Pi4jj3292+RvOCCCxbczu7du3XBBRdoz549+t3vfjfjsRjnn3++jjjiCB1xxBE688wzdccdd+g73/mObrnlFp188smSpMcee0z333//jJB26qmn6s1vfrP279+v17zmNVq9evVid0PVuNwJAACqcsIJJ2h4eHjB+drb21Uul6emJx9N8ZSnPEV33XWXzjjjDH3yk5/UW9/61oOWdXedcMIJ2rFjh3bs2KGRkRHdcsstU58/6UlPWnD7F198sd7xjndoZGREn/rUp2Y8GmP2KEwzk7vr3e9+99Q2H3jgAb3lLW+ZMd/pp5+u2267Tccff7w2btyoz372swvWcbgIaQAAoCpnnXWW4jjWwMDAVNvdd9990P1Zvb292rFjh8rlsn7605/qjjvukCT94he/ULlc1ute9zq9//3v15133ilJOuqoo/Too49Kkp773Odq7969+u53vytp4q0H99xzT011jo2N6fjjj5ckXX/99TM++8pXvqInnnhCjzzyiG699VadeuqpeuUrX6nrrrtOjz32mCTpoYce0sMPPzxjudHRUeVyOf3lX/6l3vrWt07VniQudwIAgKqYmb70pS/pkksu0Qc+8AEtW7ZMvb292rJly4z5TjvtNEVRpOc///l63vOep1NOOUXSRPh505veNHWW7YorrpAkbdy4UW9729t0xBFH6Lvf/a5u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\n", 227 | "text/plain": [ 228 | "
" 229 | ] 230 | }, 231 | "metadata": { 232 | "needs_background": "light" 233 | }, 234 | "output_type": "display_data" 235 | } 236 | ], 237 | "source": [ 238 | "plot_iris(X_1, labels_1)" 239 | ] 240 | }, 241 | { 242 | "cell_type": "markdown", 243 | "metadata": {}, 244 | "source": [ 245 | "There is one cluster according to IncrementalDBSCAN -- the points shown in green. But so far many of the points are found to be noise -- these are the grey points. The amount of noise is quite big because of the relatively low value of `eps`." 246 | ] 247 | }, 248 | { 249 | "cell_type": "markdown", 250 | "metadata": {}, 251 | "source": [ 252 | "If we pass such data points to `get_cluster_labels` that are missing from the clustering (i.e., were not inserted so far, or were inserted but also deleted), two things will happen. A warning will show up, and the labels for these missing objects will have a value of `numpy.nan`." 253 | ] 254 | }, 255 | { 256 | "cell_type": "code", 257 | "execution_count": 10, 258 | "metadata": {}, 259 | "outputs": [ 260 | { 261 | "name": "stderr", 262 | "output_type": "stream", 263 | "text": [ 264 | "/home/xyz/incdbscan/incrementaldbscan.py:141: IncrementalDBSCANWarning: No label was retrieved for object at position 5 because there is no such object in the object set.\n", 265 | " warnings.warn(\n", 266 | "/home/xyz/incdbscan/incrementaldbscan.py:141: IncrementalDBSCANWarning: No label was retrieved for object at position 6 because there is no such object in the object set.\n", 267 | " warnings.warn(\n", 268 | "/home/xyz/incdbscan/incrementaldbscan.py:141: IncrementalDBSCANWarning: No label was retrieved for object at position 7 because there is no such object in the object set.\n", 269 | " warnings.warn(\n", 270 | "/home/xyz/incdbscan/incrementaldbscan.py:141: IncrementalDBSCANWarning: No label was retrieved for object at position 8 because there is no such object in the object set.\n", 271 | " warnings.warn(\n", 272 | "/home/xyz/incdbscan/incrementaldbscan.py:141: IncrementalDBSCANWarning: No label was retrieved for object at position 9 because there is no such object in the object set.\n", 273 | " warnings.warn(\n" 274 | ] 275 | } 276 | ], 277 | "source": [ 278 | "labels_with_missing = clusterer.get_cluster_labels(X[70:80])" 279 | ] 280 | }, 281 | { 282 | "cell_type": "code", 283 | "execution_count": 11, 284 | "metadata": {}, 285 | "outputs": [ 286 | { 287 | "data": { 288 | "text/plain": [ 289 | "array([-1., -1., -1., -1., -1., nan, nan, nan, nan, nan])" 290 | ] 291 | }, 292 | "execution_count": 11, 293 | "metadata": {}, 294 | "output_type": "execute_result" 295 | } 296 | ], 297 | "source": [ 298 | "labels_with_missing" 299 | ] 300 | }, 301 | { 302 | "cell_type": "markdown", 303 | "metadata": {}, 304 | "source": [ 305 | "Let's insert the 2nd batch of data points and see the clustering of all data points." 306 | ] 307 | }, 308 | { 309 | "cell_type": "code", 310 | "execution_count": 12, 311 | "metadata": {}, 312 | "outputs": [], 313 | "source": [ 314 | "labels_all = clusterer.insert(X_2).get_cluster_labels(X)" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": 13, 320 | "metadata": {}, 321 | "outputs": [ 322 | { 323 | "data": { 324 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmkAAAJRCAYAAAAeWCASAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAAsTAAALEwEAmpwYAABEaUlEQVR4nO3dfXxkdX33//cnN84SxVFhiciaZLxZFQguZLyhKgKDN3Vd1If0IW2qxZtOZRHl6tWrv3rlqgqXc1mrbVetu3a8qVKn1pbLu7itReMNelWkEwQCIhEdEhdxWLCMQGTIJt/fHzMJm2yymTOZM+c7M6/n45FHcr5zbj7fczaZ955zvnPMOScAAAD4pSvqAgAAAHAkQhoAAICHCGkAAAAeIqQBAAB4iJAGAADgIUIaAACAh0IPaWbWbWY/NLOvrvHaRWZ20MxuqH69Jex6AAAAWkFPE7bxDkm3SnrsOq9/3jn3tibUAQAA0DJCPZNmZtsk7ZT0iTC3AwAA0G7Cvty5R9KfSlo8yjyvNbObzOwqM3tyyPUAAAC0hNAud5rZKyXd7ZybNLOz15ltXNLnnHNlM/sjSZ+RdO4a60pLSkvSox/96JFnPvOZ4RQNAADQQJOTk/c457bWs6yF9exOM3ufpNdLOiRpiyr3pH3BOff768zfLelXzrn40dabTCZdPp9vdLkAAAANZ2aTzrlkPcuGdrnTOfdO59w259yQpAslfXN1QDOzEw+bPF+VAQYAAAAdrxmjO1cwsysk5Z1zX5H0djM7X5Wzbb+SdFGz6wEAAPBRaJc7w8LlTgAA0Co2c7mz6WfSAABAa5ifn9eBAwf00EMPRV2K97Zs2aJt27apt7e3YeskpAEAgDUdOHBAxx57rIaGhmRmUZfjLeec7r33Xh04cECJRKJh6+XZnQAAYE0PPfSQjjvuOALaBsxMxx13XMPPOBLSAADAughotQljPxHSAABAzX75y1/qwgsv1FOf+lSNjIzoFa94haanp3XHHXfo1FNPrWudn/70p/WLX/xiU3V9+tOf1tvedvRHgb/nPe/RBz/4wUDrfcxjHrOZsjaFkAYAAGrinNNrXvManX322frpT3+qyclJve9971OxWNzUeusJaYcOHdrUNlsBIQ0AANTkW9/6lnp7e/XWt751ue3Zz362XvSiF62Yb/VZrVe+8pX69re/rYWFBV100UU69dRTNTw8rL/5m7/RVVddpXw+r9HRUe3YsUO/+c1vNDk5qRe/+MUaGRnRy172Mt11112SpLPPPluXXXaZksmkPvShD61b5/j4uJ73vOfp9NNP13nnnbciRN54440688wz9fSnP10f//jHl9s/8IEP6DnPeY5OO+00vfvd7z5inXfddZfOOuss7dixQ6eeeqq++93vBt+BATG6EwAA1OTmm2/WyMhI3cvfcMMNuvPOO3XzzTdLku677z497nGP09/+7d/qgx/8oJLJpObn53XppZfqy1/+srZu3arPf/7zGhsb06c+9SlJ0sMPP6yNPi/1hS98oa699lqZmT7xiU/oL//yL/VXf/VXkqSbbrpJ1157rR588EGdfvrp2rlzp26++Wb95Cc/0XXXXSfnnM4//3xdc801Ouuss5bX+Y//+I962cteprGxMS0sLGhubq7u/VArQhoAAGiKpzzlKfrZz36mSy+9VDt37tRLX/rSI+a57bbbdPPNN+slL3mJJGlhYUEnnvjIUyRf97rXbbidAwcO6HWve53uuusuPfzwwys+FuNVr3qVjjnmGB1zzDE655xzdN111+l73/uerr76ap1++umSpAceeEA/+clPVoS05zznOXrTm96k+fl5vfrVr9aOHTvq3Q0143InAACoySmnnKLJyckN5+vp6dHi4uLy9NJHUzz+8Y/XjTfeqLPPPlsf+9jH9Ja3vOWIZZ1zOuWUU3TDDTfohhtu0NTUlK6++url1x/96EdvuP1LL71Ub3vb2zQ1NaW/+7u/W/HRGKtHYZqZnHN65zvfubzN22+/XW9+85tXzHfWWWfpmmuu0UknnaSLLrpIV1555YZ1bBYhDQAA1OTcc89VuVxWNptdbrvpppuOuD9raGhIN9xwgxYXF/Xzn/9c1113nSTpnnvu0eLiol772tfqve99r66//npJ0rHHHqv7779fkvSMZzxDBw8e1Pe//31Jlace3HLLLYHqLJVKOumkkyRJn/nMZ1a89uUvf1kPPfSQ7r33Xn3729/Wc57zHL3sZS/Tpz71KT3wwAOSpDvvvFN33333iuVmZmbU39+vP/zDP9Rb3vKW5drDxOVOAABQEzPTF7/4RV122WV6//vfry1btmhoaEh79uxZMd8LXvACJRIJnXzyyXrWs56lM844Q1Il/LzxjW9cPsv2vve9T5J00UUX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\n", 325 | "text/plain": [ 326 | "
" 327 | ] 328 | }, 329 | "metadata": { 330 | "needs_background": "light" 331 | }, 332 | "output_type": "display_data" 333 | } 334 | ], 335 | "source": [ 336 | "plot_iris(X, labels_all)" 337 | ] 338 | }, 339 | { 340 | "cell_type": "markdown", 341 | "metadata": {}, 342 | "source": [ 343 | "Thanks to the new points, two clusters emerged from the set of points that were previously noise: the purple and the red cluster. " 344 | ] 345 | }, 346 | { 347 | "cell_type": "markdown", 348 | "metadata": {}, 349 | "source": [ 350 | "## Delete data points with `delete`\n", 351 | "\n", 352 | "One just has to pass the batch of data points to delete. Let's try it out by deleting the first batch of data points, and get the labels of the second batch in one line." 353 | ] 354 | }, 355 | { 356 | "cell_type": "code", 357 | "execution_count": 14, 358 | "metadata": {}, 359 | "outputs": [], 360 | "source": [ 361 | "labels_2 = clusterer.delete(X_1).get_cluster_labels(X_2)" 362 | ] 363 | }, 364 | { 365 | "cell_type": "code", 366 | "execution_count": 15, 367 | "metadata": {}, 368 | "outputs": [ 369 | { 370 | "data": { 371 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmkAAAJRCAYAAAAeWCASAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAAsTAAALEwEAmpwYAAA2QUlEQVR4nO3df5xcdX3v8fdnk+mEAC4WwwoJa0bFH0AwsINKUQQGxBp+yAMt3KZSELpVfsntbfsod6+Vet3LRbCitaIRuUBNLZWKsqZacIQi9wpxFgMbQBZ0SAzGJUQdfgSGTeZz/5jZZXfZzc7M7pnznZnX8/HYR3a+8z3nfL5zJpl3zjnfOebuAgAAQFg64i4AAAAAr0RIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAhQ5CHNzBaY2U/N7LvTPHeOmW0zsw2Vn/OjrgcAAKAZLGzANj4u6RFJr5rh+Zvd/aIG1AEAANA0Ij2SZmbLJK2SdF2U2wEAAGg1UZ/uvEbSX0sq7abPGWb2oJndYmYHRlwPAABAU4jsdKeZnSzpKXcfNLNjZ+g2IOkb7l40sz+XdKOk46dZV6+kXknac889e97ylrdEUzQAAMA8GhwcfNrdl9SzrEV1704zu0LShyXtlLRI5WvSvuXufzJD/wWSfuPunbtbbzqd9lwuN9/lAgAAzDszG3T3dD3LRna6090vc/dl7r5c0lmSfjg1oJnZ/hMenqryBAMAAIC214jZnZOY2ack5dz9NkmXmNmpKh9t+42kcxpdDwAAQIgiO90ZFU53AgCAZjGX050NP5IGAACaw+joqLZs2aIXX3wx7lKCt2jRIi1btkyJRGLe1klIAwAA09qyZYv23ntvLV++XGYWdznBcndt375dW7ZsUSqVmrf1cu9OAAAwrRdffFH77rsvAW0WZqZ999133o84EtIAAMCMCGjVieJ1IqQBAICq/frXv9ZZZ52lN7zhDerp6dH73/9+DQ8P64knntChhx5a1zpvuOEG/epXv5pTXTfccIMuumj3twK//PLLdfXVV9e03r322msuZc0JIQ0AAFTF3XX66afr2GOP1c9//nMNDg7qiiuu0MjIyJzWW09I27lz55y22QwIaQAAoCp33nmnEomEPvrRj463ve1tb9O73/3uSf2mHtU6+eSTddddd2nXrl0655xzdOihh2rFihX63Oc+p1tuuUW5XE6rV6/WypUr9cILL2hwcFDvec971NPTo5NOOklbt26VJB177LG69NJLlU6n9fnPf37GOgcGBvSOd7xDhx9+uE444YRJIfKBBx7QUUcdpYMOOkhf/epXx9uvuuoqHXnkkTrssMP0yU9+8hXr3Lp1q4455hitXLlShx56qH70ox/V/gLWiNmdAACgKhs3blRPT0/dy2/YsEFPPvmkNm7cKEn63e9+p3322Udf/OIXdfXVVyudTmt0dFQXX3yxvvOd72jJkiW6+eab1dfXp+uvv16S9NJLL2m270t917vepXvvvVdmpuuuu06f+cxn9NnPflaS9OCDD+ree+/V888/r8MPP1yrVq3Sxo0b9dhjj2n9+vVyd5166qm6++67dcwxx4yv85//+Z910kknqa+vT7t27dKOHTvqfh2qRUgDAAAN8frXv16/+MUvdPHFF2vVqlV673vf+4o+jz76qDZu3KgTTzxRkrRr1y7tv//Ld5E888wzZ93Oli1bdOaZZ2rr1q166aWXJn0txmmnnaY99thDe+yxh4477jitX79e99xzj26//XYdfvjhkqTnnntOjz322KSQduSRR+ojH/mIRkdH9YEPfEArV66s92WoGqc7AQBAVQ455BANDg7O2m/hwoUqlUrjj8e+muLVr361HnjgAR177LH68pe/rPPPP/8Vy7q7DjnkEG3YsEEbNmzQ0NCQbr/99vHn99xzz1m3f/HFF+uiiy7S0NCQvvKVr0z6aoypszDNTO6uyy67bHybjz/+uM4777xJ/Y455hjdfffdWrp0qc455xzddNNNs9YxV4Q0AABQleOPP17FYlFr1qwZb3vwwQdfcX3W8uXLtWHDBpVKJf3yl7/U+vXrJUlPP/20SqWSzjjjDH3605/W/fffL0nae++99eyzz0qS3vzmN2vbtm368Y9/LKl814OHHnqopjoLhYKWLl0qSbrxxhsnPfed73xHL774orZv36677rpLRx55pE466SRdf/31eu655yRJTz75pJ566qlJy23atEldXV36sz/7M51//vnjtUeJ050AAKAqZqZbb71Vl156qa688kotWrRIy5cv1zXXXDOp39FHH61UKqWDDz5Yb33rW3XEEUdIKoefc889d/wo2xVXXCFJOuecc/TRj35Ue+yxh3784x/rlltu0SWXXKJCoaCdO3fq0ksv1SGHHFJ1nZdffrk+9KEP6dWvfrWOP/545fP58ecOO+wwHXfccXr66af1iU98QgcccIAOOOAAPfLIIzrqqKMklb924+tf/7r222+/8eXuuusuXXXVVUokEtprr70aciSNG6wDAIBpPfLII3rrW98adxlNY7rXay43WOd0JwAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAAB4nvSAABAy1q+fLn23ntvLViwQAsXLpz1vp8hIaQBAICWduedd+o1r3lN3GXUjNOdAAAAASKkAQCA2A2tHdI1y6/R33X8na5Zfo2G1g7Ny3rNTO9973vV09Mz6Z6jzYDTnQAAIFZDa4c00Dug0R2jkqTCpoIGegckSStWr5jTuu+55x4tXbpUTz31lE488US95S1v0THHHDPnmhuBI2kAACBW2b7seEAbM7pjVNm+7JzXvXTpUknSfvvtp9NPP13r16+f8zobhZAGAABiVdhcqKm9Ws8//7yeffbZ8d9vv/12HXrooXNaZyNxuhMAAMSqs7tThU2vDGSd3Z1zWu/IyIhOP/10SdLOnTv1x3/8x3rf+943p3U2EiENAADEKtOfmXRNmiQlFieU6c/Mab2vf/3r9cADD8y1vNgQ0gAAQKzGJgdk+7IqbC6os7tTmf7MnCcNNDtCGgAAiN2K1SvaPpRNxcQBAACAABHSAAAAAkRIAwAACBAhDQAAIECENAAA0PJ+9rOf6aijjlIymdTVV18ddzlVYXYnAABoeb//+7+vL3zhC/r2t78ddylV40gaAABoefvtt5+OPPJIJRKJuEupGkfSAABA7EZGRpTP51UsFpVMJpVKpdTV1RV3WbEipAEAgFiNjIxoeHhYpVJJklQsFjU8PCxJbR3UON0JAABilc/nxwPamFKppHw+P6f1/uM//qNWrlyplStX6le/+tWc1hUHjqQBAIBYFYvFmtqrdeGFF+rCCy+c0zriREgDAACxSiaT0wayZDI5b9v49a9/rXQ6rWeeeUYdHR265ppr9PDDD+tVr3rVvG1jvhHSAABArFKp1KRr0iSpo6NDqVRq3rbx2te+Vlu2bJm39TUCIQ0AAMRqbHIAszsnI6QBAIDYdXV1tX0om4rZnQAAAAEipAEAgBm5e9wlNIUoXidCGgAAmNaiRYu0fft2gtos3F3bt2/XokWL5nW9XJMGAACmtWzZMm3ZskXbtm2Lu5TgLVq0SMuWLZvXdRLSAADAtBKJxLx+DQZqw+lOAACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECRhzQzW2BmPzWz707zXNLMbjazx83sPjNbHnU9AAAAzaARR9I+LumRGZ47T9Jv3f2Nkj4n6coG1AMAABC8SEOamS2TtErSdTN0OU3SjZXfb5GUMTOLsiYAAIBmEPWRtGsk/bWk0gzPL5X0S0ly952SCpL2jbgmAACA4EUW0szsZElPufvgPKyr18xyZpbbtm3bPFQHAAAQtiiPpB0t6VQze0LSv0g63sy+PqXPk5IOlCQzWyipU9L2qSty9zXunnb39JIlSyIsGQAAIAyRhTR3v8zdl7n7cklnSfqhu//JlG63SfrTyu8frPTxqGoCAABoFgsbvUEz+5SknLvfJulrkv7JzB6X9BuVwxwAAEDba0hIc/e7JN1V+f1vJ7S/KOlDjagBAACgmXDHAQAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQAvjLgAAAMyPkZER5fN5FYtFJZNJpVIpdXV1Nd02UEZIAwCgBYyMjGh4eFilUkmSVCwWNTw8LEnzFqIasQ28jNOdAAC0gHw+Px6expRKJeXz+abaBl5GSAMAoAUUi8Wa2kPdBl5GSAMAoAUkk8ma2kPdBl5GSAMAoAWkUil1dEz+WO/o6FAqlWqqbeBlTBwAAKAFjF24H+XMy0ZsAy8jpAEA0CK6uroiD0yN2AbKON0JAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAFaGHcBAADMt5GREeXzeRWLRSWTSaVSKXV1dcVdFlATQhoAoKWMjIxoeHhYpVJJklQsFjU8PCxJBDU0FU53AgBaSj6fHw9oY0qlkvL5fEwVAfUhpAEAWkqxWKypHQgVIQ0A0FKSyWRN7UCoCGkAgJaSSqXU0TH5462jo0OpVCqmioD6MHEAANBSxiYHMLsTzY6QBgBoOV1dXYQyND1OdwIAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAiC2lmtsjM1pvZA2b2kJn93TR9zjGzbWa2ofJzflT1AAAANJMob7BelHS8uz9nZglJ95jZ99z93in9bnb3iyKsAwAAoOlEFtLc3SU9V3mYqPx4VNsDAABoJZFek2ZmC8xsg6SnJN3h7vdN0+0MM3vQzG4xswOjrAcAAKBZRBrS3H2Xu6+UtEzS283s0CldBiQtd/fDJN0h6cbp1mNmvWaWM7Pctm3boiwZAAAgCA2Z3enuv5N0p6T3TWnf7u7FysPrJPXMsPwad0+7e3rJkiWR1goAABCCKGd3LjGzfSq/7yHpREk/m9Jn/wkPT5X0SFT1AAAANJMoZ3fuL+lGM1ugchj8V3f/rpl9SlLO3W+TdImZnSppp6TfSDonwnoAAACahpUnYTaPdDrtuVwu7jIAAABmZWaD7p6uZ1nuOAAAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAARoYdwFAMDIyIjy+byKxaKSyaRSqZS6urrmdRtDa4eU7cuqsLmgzu5OZfozWrF6xbz1B4D5RkgDEKuRkRENDw+rVCpJkorFooaHhyVp3oLa0NohDfQOaHTHqCSpsKmggd4BSZo2eNXaHwCiwOlOALHK5/PjAW1MqVRSPp+ft21k+7LjgWvM6I5RZfuy89IfAKJASAMQq2KxWFN7PQqbC5G2A0AUCGkAYpVMJmtqr0dnd2ek7QAQBUIagFilUil1dEz+p6ijo0OpVGretpHpzyixODGpLbE4oUx/Zl76A0AUmDgAIFZjkwOinN05drF/tbM1a+0PAFEwd4+7hpqk02nP5XJxlwEAADArMxt093Q9y3K6EwAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQLPeFsrM0pLeLekASS9I2ijpDnf/bcS1AQAAtK0Zj6SZ2blmdr+kyyTtIelRSU9JepekH5jZjWbW3ZgyAQAA2svujqQtlnS0u78w3ZNmtlLSQZI2R1AXAMRqaO1QS9xgfd0F6zS4ZlC+y2ULTD29PVr1pVVxlwWgCjOGNHf/x90t6O4b5r0aAAjA0NohDfQOaHTHqCSpsKmggd4BSWqqoLbugnXKXZsbf+y7fPwxQQ0I36wTB8wsZWZ/b2bfMrPbxn4aURwAxCHblx0PaGNGd4wq25eNqaL6DK4ZrKkdQFhmnTgg6duSviZpQFIp0moAIACFzYWa2kPlu7ymdgBhqSakvejuX4i8EgAIRGd3pwqbXhnIOrs7Y6imfrbApg1ktsBiqAZArar5nrTPm9knzewoMzti7CfyygAgJpn+jBKLE5PaEosTyvRnYqqoPj29PTW1AwhLNUfSVkj6sKTj9fLpTq88BoCWMzY5oNlnd45NDmB2J9CczH331yaY2eOSDnb3lxpT0u6l02nP5XKzdwQAAIiZmQ26e7qeZas53blR0j71rBwAAAD1qeZ05z6SfmZmP5FUHGt091OjKgoAAKDdVRPSPhl5FQAAAJikmpC2WdJWd39RksxsD0ldkVYFAADQ5qq5Ju2bmvwltrsqbQAAAIhINSFt4cSZnZXffy+6kgAAAFBNSNtmZuOTBMzsNElPR1cSAAAAqrkm7aOS1prZFyuPt6j85bYAAACIyKwhzd1/LumdZrZX5fFzkVcFAADQ5mY83Wlmf2Jm48+7+3MTA5qZvcHM3hV1gQAAAO1od0fS9pX0UzMblDQoaZukRZLeKOk9Kl+X9jeRVwgAANCGZgxp7v75ynVox0s6WtJhkl6Q9IikD7v75saUCAAA0H52e02au++SdEflBwCwG0Nrh5Tty6qwuaDO7k5l+jNasXrFvPVvhHpqapVxAKGpZnYnAGAWQ2uHNNA7oNEdo5KkwqaCBnoHJGnacFBr/0aop6ZWGQcQomq+Jw0AMItsX3Y8FIwZ3TGqbF92Xvo3Qj01tco4gBAR0gBgHhQ2FyJtb4R6amqVcQAhmvV0p5klJZ0hafnE/u7+qejKAoDm0tndqcKmV4aAzu7OeenfCPXU1CrjAEJUzZG070g6TdJOSc9P+AEAVGT6M0osTkxqSyxOKNOfmZf+jVBPTa0yDiBE1UwcWObu74u8EgBoYmMXpFc7o7DW/o1QT02tMg4gRObuu+9gtkbSP7j7UGNK2r10Ou25XC7uMgAAAGZlZoPunq5n2RmPpJnZkCSv9DnXzH4hqSjJJLm7H1bPBgEAADC73Z3uPLlhVQAAAGCS3d0WapMkmdk/ufuHJz5nZv8k6cPTLggAAIA5q2Z25yETH5jZAkk90ZQDAAAAaTchzcwuM7NnJR1mZs9Ufp6V9JTKX8sBAACAiMwY0tz9CnffW9JV7v6qys/e7r6vu1/WwBoBAADazu5mdx5R+fWbE34f5+73R1YVAABAm9vd7M7PVv5cJCkt6QGVv37jMEk5SUdFWxoAAED72t3pzuPc/ThJWyUd4e5pd++RdLikJxtVIAAAQDuqZnbnmyfebcDdN0p6a3QlAQAAoJp7dz5oZtdJ+nrl8WpJD0ZXEgAAAKoJaedK+pikj1ce3y3p2sgqAgAAwOwhzd1flPS5yg8AYAZDa4eU7cuqsLmgzu5OZfozWrF6Raw1rbtgnQbXDMp3uWyBqae3R6u+tKrptgG0o919Bce/uvsfTbjR+iTcYB0AXja0dkgDvQMa3TEqSSpsKmigd0CSYgtq6y5Yp9y1ufHHvsvHH89XiGrENoB2tbuJA2OnN0+WdMo0PwCAimxfdjygjRndMapsXzamiqTBNYM1tYe6DaBd7e4G61srv54g6W53f6wxJQFA8ylsLtTU3gi+6xUnQXbbHuo2gHZVzVdwdEv6ipn9wsy+aWYXm9nKiOsCgKbS2d1ZU3sj2AKrqT3UbQDtataQ5u6fdPfjJR0i6UeS/koSx7EBYIJMf0aJxYlJbYnFCWX6MzFVJPX09tTUHuo2gHY16+xOM/sfko6WtJekn0r6S5XDGgCgYmxyQEizO8cu3I9y5mUjtgG0K3Pf/XUDZna/pJ2S1kn6T0k/dvdiA2qbVjqd9lwuN3tHAACAmJnZoLun61m2mtOdR6g8eWC9pBMlDZnZPfVsDAAAANWp5nTnoZLeLek9ktKSfilOdwIAAESqmttC/W+VbwX1BUk/cffRWfoDAABgjqq5LdTJjSgEAAAAL6vme9IAAADQYIQ0AACAABHSAAAAAjTjNWlmNiBpxi9Rc/dTI6kIAAAAu504cHXDqgAAAMAkM4Y0d//PRhYCAACAl1XzZbYHSbpC0sGSFo21u/vrI6wLAACgrVUzceD/SLpW5ft3HifpJklfn20hM1tkZuvN7AEze8jM/m6aPkkzu9nMHjez+8xseY31AwAAtKRq7jiwh7tnzczcfZOky81sUNLfzrJcUdLx7v6cmSUk3WNm33P3eyf0OU/Sb939jWZ2lqQrJZ1Zz0CAEA2tHVK2L6vC5oI6uzuV6c9oxeoVsW6jVWpad8E6Da4ZlO9y2QJTT2+PVn1p1byOI2o3nXCT8tn8+ONUJqWzf3D2bpep9bWq9XVqxPujHiG+zxthZGRE+XxexWJRyWRSqVRKXV1d89YfYasmpBXNrEPSY2Z2kaQnJe0120Lu7pKeqzxMVH6mzhY9TdLlld9vkfTFShiccVYp0CyG1g5poHdAozvKd1IrbCpooHdAkubtw6LWbbRKTesuWKfctbnxx77Lxx83S1CbGtAkKZ/N66YTbpoxqNX6WtX6OjXi/VGPEN/njTAyMqLh4WGVSiVJUrFY1PDwsCRNG7xq7Y/wVXO68+OSFku6RFKPpA9L+tNqVm5mC8xsg6SnJN3h7vdN6bJU5Ru2y913SipI2reqyoHAZfuy4x8SY0Z3jCrbl41tG61S0+CawZraQzQ1oM3WLtX+WtX6OjXi/VGPEN/njZDP58cD15hSqaR8fob3To39Eb5q7t35E0mqHE27xN2frXbl7r5L0koz20fSrWZ2qLtvrLVIM+uV1CtJ3d3dtS4OxKKwuVBTeyO20So1+a7pD7bP1N4qan2tan2dGvH+qEeI7/NGKBaLkbYjfLMeSTOztJkNSXpQ0lBlIkBPLRtx999JulPS+6Y89aSkAyvbWSipU9L2aZZf4+5pd08vWbKklk0Dsens7qypvRHbaJWabIHV1N4qan2tan2dGvH+qEeI7/NGSCaTkbYjfNWc7rxe0gXuvtzdl0u6UOUZn7tlZksqR9BkZntIOlHSz6Z0u00vnzr9oKQfcj0aWkWmP6PE4sSktsTihDL9mdi20So19fRO///EmdpDlMqkamqXan+tan2dGvH+qEeI7/NGSKVS6uiY/DHd0dGhVGqG906N/RG+akLaLnf/0dgDd79H5a/jmM3+ku40swcl/UTla9K+a2afMrOxW0p9TdK+Zva4pL+Q9De1lQ+Ea8XqFTplzSnqfF2nZFLn6zp1yppT5vXC5Vq30So1rfrSKqU/lh4/ImQLTOmPpZtm0oAknf2Ds18RyGab3Vnra1Xr69SI90c9QnyfN0JXV5fe9KY3jR8JSyaTetOb3jTjJIBa+yN8NtuBKzO7RtIekr6h8uzMMyW9qMp3pbn7/dGWOFk6nfZcLjd7RwAAgJiZ2aC7p+tZtpqv4Hhb5c9PTmk/XOXQdnw9GwYAAMDMqpndeVwjCgEAAMDLqpnd2WVmXzOz71UeH2xm50VfGgAAQPuqZuLADZL+Q9IBlcfDki6NqB4AAACoupD2Gnf/V0klafzOALsirQoAAKDNVRPSnjezfVW576aZvVPl2zcBAAAgItXM7vwLlb909g1m9n8lLVH5i2cBAAAQkWpmd95vZu+R9GZJJulRdx+dZTEAAADMQTWzOz8kaQ93f0jSByTdbGZHRF0YAABAO6vmmrRPuPuzZvYuSRmVb+V0bbRlAQAAtLeq7t1Z+XOVpK+6+zpJvxddSQAAAKhm4sCTZvYVSSdKutLMkqou3AEI0NDaIWX7sipsLqizu1OZ/kzsN55uRE21biPE1wnRYX8jRNWEtD+S9D5JV7v778xsf0l/FW1ZAKIwtHZIA70DGt1RnvtT2FTQQO+AJMX2gdSImmrdRoivE6LD/kaoZj0i5u473P1b7v5Y5fFWd789+tIAzLdsX3b8g2jM6I5RZfuyMVXUmJpq3UaIrxOiw/5GqDhtCbSRwubpv4d6pvZGaERNtW4jxNcJ0WF/I1SENKCNdHZ31tTeCI2oqdZthPg6ITrsb4SKkAa0kUx/RonFiUlticUJZfozMVXUmJpq3UaIrxOiw/5GqKqZOACgRYxdBB3SLLZG1FTrNkJ8nRAd9jdCZe4edw01SafTnsvl4i4DAABgVmY26O7pepbldCcAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABCghXEXAGBuhtYOKduXVWFzQZ3dncr0Z7Ri9YrY+oeqEeNed8E6Da4ZlO9y2QJTT2+PVn1p1XwPJVKtsr8bYWRkRPl8XsViUclkUqlUSl1dXdSEeUNIA5rY0NohDfQOaHTHqCSpsKmggd4BSZr2gzXq/qFqxLjXXbBOuWtz4499l48/bpag1ir7uxFGRkY0PDysUqkkSSoWixoeHpak2EJRiDVhbjjdCTSxbF92/AN1zOiOUWX7srH0D1Ujxj24ZrCm9hC1yv5uhHw+Px6GxpRKJeXz+ZgqCrMmzA0hDWhihc2FoNpD1Yhx+y6vqT1ErbK/G6FYLNbU3ggh1oS5IaQBTayzuzOo9lA1Yty2wGpqD1Gr7O9GSCaTNbU3Qog1YW4IaUATy/RnlFicmNSWWJxQpj8TS/9QNWLcPb09NbWHqFX2dyOkUil1dEz+CO3o6FAqlYqpojBrwtwwcQBoYmMXc1c7Gy/q/qFqxLjHJgc08+zOVtnfjTB2IX5IMylDrAlzY+7Nc72EJKXTac/lcrN3BAAAiJmZDbp7up5lOd0JAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEaGHcBQCYm6G1Q8r2ZVXYXFBnd6cy/RmtWL1i3tZ/0wk3KZ/Njz9OZVI6+wdnz9v6pejH0KiaQhwHgObFkTSgiQ2tHdJA74AKmwqSS4VNBQ30Dmho7dC8rH9qQJOkfDavm064aV7WL0U/hkbVFOI4ADQ3QhrQxLJ9WY3uGJ3UNrpjVNm+7Lysf2pAm629HlGPoR711BTiOAA0N0Ia0MQKmws1tYcoxDHUU1OI4wDQ3AhpQBPr7O6sqT1EIY6hnppCHAeA5kZIA5pYpj+jxOLEpLbE4oQy/Zl5WX8qk6qpvR5Rj6Ee9dQU4jgANDdCGtDEVqxeoVPWnKLO13VKJnW+rlOnrDll3mYUnv2Ds18RyOZ7dmfUY2hUTSGOA0BzM3ePu4aapNNpz+VycZcBAAAwKzMbdPd0PctyJA0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAjQwrgLAPCyobVDyvZlVdhcUGd3pzL9Ga1YvSLWbTSipnqEWhcQp5GREeXzeRWLRSWTSaVSKXV1dcVdFupESAMCMbR2SAO9AxrdMSpJKmwqaKB3QJLmLXzUuo1G1FSPUOsC4jQyMqLh4WGVSiVJUrFY1PDwsCQR1JoUpzuBQGT7suOhY8zojlFl+7KxbaMRNdUj1LqAOOXz+fGANqZUKimfz8dUEeaKkAYEorC5UFN7I7bRiJrqEWpdQJyKxWJN7QgfIQ0IRGd3Z03tjdhGI2qqR6h1AXFKJpM1tSN8hDQgEJn+jBKLE5PaEosTyvRnYttGI2qqR6h1AXFKpVLq6Jj8sd7R0aFUKhVTRZgrJg4AgRi74D3KGYu1bqMRNdUj1LqAOI1NDmB2Z+swd4+7hpqk02nP5XJxlwEAADArMxt093Q9y3K6EwAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAhRZSDOzA83sTjN72MweMrOPT9PnWDMrmNmGys/fRlUPAABAM1kY4bp3Svpv7n6/me0tadDM7nD3h6f0+5G7nxxhHQjAyMiI8vm8isWiksmkUqmUurq6mm4bURtaO6RsX1aFzQV1dncq05/RitUrmm4braBVXqdW+HsBtKvIQpq7b5W0tfL7s2b2iKSlkqaGNLS4kZERDQ8Pq1QqSZKKxaKGh4clad4+LBqxjagNrR3SQO+ARneMSpIKmwoa6B2QpHkLB43YRitoldepFf5eAO2sIdekmdlySYdLum+ap48yswfM7Htmdkgj6kFj5fP58Q+JMaVSSfl8vqm2EbVsX3Y8FIwZ3TGqbF+2qbbRClrldWqFvxdAO4vydKckycz2kvRvki5192emPH2/pNe5+3Nm9n5J35Z00DTr6JXUK0nd3d3RFox5VywWa2oPdRtRK2wu1NQe6jZaQau8Tq3w9wJoZ5EeSTOzhMoBba27f2vq8+7+jLs/V/n93yUlzOw10/Rb4+5pd08vWbIkypIRgWQyWVN7qNuIWmd3Z03toW6jFbTK69QKfy+Adhbl7E6T9DVJj7j738/Q57WVfjKzt1fq2R5VTYhHKpVSR8fkt1pHR4dSqVRTbSNqmf6MEosTk9oSixPK9GeaahutoFVep1b4ewG0syhPdx4t6cOShsxsQ6Xtv0vqliR3/7KkD0r6mJntlPSCpLPc3SOsCTEYu0A5yhlmjdhG1MYuSI9yRmEjttEKWuV1aoW/F0A7s2bLROl02nO5XNxlAAAAzMrMBt09Xc+y3HEAAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAACFOUN1gHUaN0F6zS4ZlC+y2ULTD29PVr1pVVxlwUAiAEhDQjEugvWKXdtbvyx7/LxxwQ1AGg/nO4EAjG4ZrCmdgBAayOkAYHwXV5TOwCgtRHSgEDYAqupHQDQ2ghpQCB6entqagcAtDYmDgCBGJscwOxOAIAkmXtzXe+STqc9l8vN3hEAACBmZjbo7ul6luV0JwAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAEKCFcRcATGdkZET5fF7FYlHJZFKpVEpdXV1xl4U2MrR2SNm+rAqbC+rs7lSmP6MVq1fEXRaANkJIQ3BGRkY0PDysUqkkSSoWixoeHpYkghoaYmjtkAZ6BzS6Y1SSVNhU0EDvgCQR1AA0DKc7EZx8Pj8e0MaUSiXl8/mYKkK7yfZlxwPamNEdo8r2ZWOqCEA7IqQhOMVisaZ2YL4VNhdqageAKBDSEJxkMllTOzDfOrs7a2oHgCgQ0hCcVCqljo7Jb82Ojg6lUqmYKkK7yfRnlFicmNSWWJxQpj8TU0UA2hETBxCcsckBzO5EXMYmBzC7E0CczN3jrqEm6XTac7lc3GUAAADMyswG3T1dz7Kc7gQAAAgQIQ0AACBAhDQAAIAAEdIAAAACREgDAAAIECENAAAgQIQ0AACAABHSAAAAAkRIAwAACBAhDQAAIECENAAAgAAR0gAAAAK0MO4CAGBkZET5fF7FYlHJZFKpVEpdXV1xlwUAsSKkAYjVyMiIhoeHVSqVJEnFYlHDw8OSRFAD0NY43QkgVvl8fjygjSmVSsrn8zFVBABhIKQBiFWxWKypHQDaBSENQKySyWRN7QDQLghpAGKVSqXU0TH5n6KOjg6lUqmYKgKAMDBxAECsxiYHMLsTACYjpAGIXVdXF6EMAKbgdCcAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAAYospJnZgWZ2p5k9bGYPmdnHp+ljZvYFM3vczB40syOiqgcAAKCZLIxw3Tsl/Td3v9/M9pY0aGZ3uPvDE/r8oaSDKj/vkHRt5U8AAIC2FtmRNHff6u73V35/VtIjkpZO6XaapJu87F5J+5jZ/lHVBAAA0Cwack2amS2XdLik+6Y8tVTSLyc83qJXBjkAAIC2E3lIM7O9JP2bpEvd/Zk619FrZjkzy23btm1+CwQAAAhQpCHNzBIqB7S17v6tabo8KenACY+XVdomcfc17p529/SSJUuiKRYAACAgUc7uNElfk/SIu//9DN1uk3R2ZZbnOyUV3H1rVDUBAAA0iyhndx4t6cOShsxsQ6Xtv0vqliR3/7Kkf5f0fkmPS9oh6dwI6wEAAGgakYU0d79Hks3SxyVdGFUNAAAAzYo7DgAAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABIiQBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAYAABAgQhoAAECACGkAAAABIqQBAAAEiJAGAAAQIEIaAABAgAhpAAAAASKkAQAABMjcPe4aamJmz0p6NO46YvAaSU/HXUQMGHd7YdzthXG3l3Yd95vdfe96Flw435U0wKPuno67iEYzsxzjbh+Mu70w7vbCuNuLmeXqXZbTnQAAAAEipAEAAASoGUPamrgLiAnjbi+Mu70w7vbCuNtL3eNuuokDAAAA7aAZj6QBAAC0vKBDmpktMLOfmtl3p3kuaWY3m9njZnafmS2PocRIzDLuc8xsm5ltqPycH0eN883MnjCzocqYXjETxsq+UNnfD5rZEXHUOd+qGPexZlaYsL//No4655uZ7WNmt5jZz8zsETM7asrzrbq/Zxt3y+1vM3vzhPFsMLNnzOzSKX1abn9XOe6W29+SZGb/1cweMrONZvYNM1s05fmW/PyuYtw1f36H/hUcH5f0iKRXTfPceZJ+6+5vNLOzJF0p6cxGFheh3Y1bkm5294saWE+jHOfuM32Hzh9KOqjy8w5J11b+bAW7G7ck/cjdT25YNY3xeUnfd/cPmtnvSVo85flW3d+zjVtqsf3t7o9KWimV/wMq6UlJt07p1nL7u8pxSy22v81sqaRLJB3s7i+Y2b9KOkvSDRO6tdznd5Xjlmr8/A72SJqZLZO0StJ1M3Q5TdKNld9vkZQxM2tEbVGqYtzt6jRJN3nZvZL2MbP94y4KtTOzTknHSPqaJLn7S+7+uyndWm5/VznuVpeR9HN33zSlveX29xQzjbtVLZS0h5ktVPk/Ir+a8nxLfn5r9nHXLNiQJukaSX8tqTTD80sl/VKS3H2npIKkfRtSWbSu0e7HLUlnVE4J3GJmBzamrMi5pNvNbNDMeqd5fnx/V2yptDW72cYtSUeZ2QNm9j0zO6SRxUUkJWmbpP9j5dP615nZnlP6tOL+rmbcUuvt74nOkvSNadpbcX9PNNO4pRbb3+7+pKSrJW2WtFVSwd1vn9Kt5T6/qxy3VOPnd5AhzcxOlvSUuw/GXUsjVTnuAUnL3f0wSXfo5f+NNLt3ufsRKp/2uNDMjom7oAaZbdz3S3qdu79N0j9I+naD64vCQklHSLrW3Q+X9Lykv4m3pIaoZtytuL8lSZXTu6dK+mbctTTSLONuuf1tZq9W+UhZStIBkvY0sz+Jt6roVTnumj+/gwxpko6WdKqZPSHpXyQdb2Zfn9LnSUkHSlLl0GKnpO2NLDICs47b3be7e7Hy8DpJPY0tMRqV/4XI3Z9S+bqNt0/pMr6/K5ZV2prabON292fc/bnK7/8uKWFmr2l4ofNri6Qt7n5f5fEtKoeXiVpxf8867hbd32P+UNL97j4yzXOtuL/HzDjuFt3fJ0jKu/s2dx+V9C1JfzClTyt+fs867no+v4MMae5+mbsvc/flKh8m/qG7T02kt0n608rvH6z0aeovfatm3FOu0zhV5QkGTc3M9jSzvcd+l/ReSRundLtN0tmVWWDvVPlQ8tYGlzqvqhm3mb127FoNM3u7yn9nm/ofM3f/taRfmtmbK00ZSQ9P6dZy+7uacbfi/p7gv2jmU34tt78nmHHcLbq/N0t6p5ktrowto1d+TrXc57eqGHc9n9+hz+6cxMw+JSnn7repfPHtP5nZ45J+o3KoaUlTxn2JmZ0qaafK4z4nztrmSZekWyv/Vi2U9M/u/n0z+6gkufuXJf27pPdLelzSDknnxlTrfKpm3B+U9DEz2ynpBUlntcA/ZpJ0saS1lVNBv5B0bhvsb2n2cbfk/q78J+RESX8+oa3l93cV4265/e3u95nZLSqfyt0p6aeS1rT653eV467585s7DgAAAAQoyNOdAAAA7Y6QBgAAECBCGgAAQIAIaQAAAAEipAEAAASIkAagKZnZsWb23Wrb52F7HzCzgyc8vsvM0lUst/981GNmS8zs+3NdD4DmQUgDgOp8QNLBs3Waxl9I+upcN+7u2yRtNbOj57ouAM2BkAYgEpU7Kqyr3Dx6o5mdWWnvMbP/tPJN5f9j7Fu4K0emPm9mGyr9315pf7uZ/djKNyX/fxO+sb/aGq43s/WV5U+rtJ9jZt8ys++b2WNm9pkJy5xnZsOVZb5qZl80sz9Q+RvCr6rU94ZK9w9V+g2b2btnKOMMSd+vrHuBmV1dGd+DZnZxpf0JM7uisu6cmR1ReW1+PvblpxXflrS62vEDaG5NdccBAE3lfZJ+5e6rJMnMOs0sofKNpE9z922V4NYv6SOVZRa7+0or32j+ekmHSvqZpHe7+04zO0HS/1I5+FSjT+VbznzEzPaRtN7MflB5bqWkwyUVJT1qZv8gaZekT6h8T81nJf1Q0gPu/v/M7DZJ33X3WyrjkaSF7v52M3u/pE+qfP++cWaWkvTbCffr65W0XNLKynh+f0L3zZWxf07SDSrfy3eRyrcK+3KlT07Sp6scO4AmR0gDEJUhSZ81sytVDjc/MrNDVQ5ed1RCzgJJE+/R+A1Jcve7zexVlWC1t6QbzewgSS4pUUMN75V0qpn9ZeXxIkndld+z7l6QJDN7WNLrJL1G0n+6+28q7d+U9KbdrP9blT8HVQ5fU+0vaduExydI+rK776yM8zcTnrut8ueQpL3c/VlJz5pZ0cz2cfffSXpK0gG7HTGAlkFIAxAJdx82syNUvifjp80sK+lWSQ+5+1EzLTbN4/8p6U53P93Mlku6q4YyTNIZ7v7opEazd6h8BG3MLtX37+HYOmZa/gWVg2Et6ypNqa00Yd2LKusE0Aa4Jg1AJMzsAEk73P3rkq5S+RTio5KWmNlRlT4JMztkwmJj1629S1KhcqSrU9KTlefPqbGM/5B0sVUO25nZ4bP0/4mk95jZq81soSafVn1W5aN6tRjW5CNsd0j688q6NeV0ZzXepPLpTwBtgJAGICorVL4GbIPK12t92t1fkvRBSVea2QOSNkj6gwnLvGhmP1X5GqzzKm2fkXRFpb3Wo13/U+XTow+a2UOVxzNy9ydVvuZtvaT/K+kJSYXK0/8i6a8qExDeMP0aXrG+5yX93MzeWGm6TtLmSj0PSPrj2oaj4yStq3EZAE3K3KeeXQCAxjOzuyT9pbvnYq5jL3d/rnK061ZJ17v7rXNY3+mSetz9f8xDbXerPOnit3NdF4DwcSQNACa7vHL0b6OkvMpfe1G3SsB7Yq5FmdkSSX9PQAPaB0fSAAAAAsSRNAAAgAAR0gAAAAJESAMAAAgQIQ0AACBAhDQAAIAAEdIAAAAC9P8Bf41SzmBcTI8AAAAASUVORK5CYII=\n", 372 | "text/plain": [ 373 | "
" 374 | ] 375 | }, 376 | "metadata": { 377 | "needs_background": "light" 378 | }, 379 | "output_type": "display_data" 380 | } 381 | ], 382 | "source": [ 383 | "plot_iris(X_2, labels_2)" 384 | ] 385 | }, 386 | { 387 | "cell_type": "markdown", 388 | "metadata": {}, 389 | "source": [ 390 | "Only the purple cluster remained.\n", 391 | "\n", 392 | "But remember that we inserted the first 10 data points from the first batch two times? When we invoked `delete(X_1)`, one of the duplicated points remained. So we still can get the labels of the first 10 points. Just by peeking into the values we can see that they became noise." 393 | ] 394 | }, 395 | { 396 | "cell_type": "code", 397 | "execution_count": 16, 398 | "metadata": {}, 399 | "outputs": [], 400 | "source": [ 401 | "labels_10 = clusterer.get_cluster_labels(X_1[:10])" 402 | ] 403 | }, 404 | { 405 | "cell_type": "code", 406 | "execution_count": 17, 407 | "metadata": {}, 408 | "outputs": [ 409 | { 410 | "data": { 411 | "text/plain": [ 412 | "array([-1., -1., -1., -1., -1., -1., -1., -1., -1., -1.])" 413 | ] 414 | }, 415 | "execution_count": 17, 416 | "metadata": {}, 417 | "output_type": "execute_result" 418 | } 419 | ], 420 | "source": [ 421 | "labels_10" 422 | ] 423 | }, 424 | { 425 | "cell_type": "markdown", 426 | "metadata": {}, 427 | "source": [ 428 | "One thing left to note. If we try to delete such points that are missing from the clustering, we will not succeed but have to face a warning. Here first we check what happens if we try to delete the first 10 points (which currently are still in the clustering), and then what happens if we try to delete them again." 429 | ] 430 | }, 431 | { 432 | "cell_type": "code", 433 | "execution_count": 18, 434 | "metadata": {}, 435 | "outputs": [ 436 | { 437 | "data": { 438 | "text/plain": [ 439 | "" 440 | ] 441 | }, 442 | "execution_count": 18, 443 | "metadata": {}, 444 | "output_type": "execute_result" 445 | } 446 | ], 447 | "source": [ 448 | "clusterer.delete(X_1[:10])" 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": 19, 454 | "metadata": {}, 455 | "outputs": [ 456 | { 457 | "name": "stderr", 458 | "output_type": "stream", 459 | "text": [ 460 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 0 was not deleted because there is no such object in the object set.\n", 461 | " warnings.warn(\n", 462 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 1 was not deleted because there is no such object in the object set.\n", 463 | " warnings.warn(\n", 464 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 2 was not deleted because there is no such object in the object set.\n", 465 | " warnings.warn(\n", 466 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 3 was not deleted because there is no such object in the object set.\n", 467 | " warnings.warn(\n", 468 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 4 was not deleted because there is no such object in the object set.\n", 469 | " warnings.warn(\n", 470 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 5 was not deleted because there is no such object in the object set.\n", 471 | " warnings.warn(\n", 472 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 6 was not deleted because there is no such object in the object set.\n", 473 | " warnings.warn(\n", 474 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 7 was not deleted because there is no such object in the object set.\n", 475 | " warnings.warn(\n", 476 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 8 was not deleted because there is no such object in the object set.\n", 477 | " warnings.warn(\n", 478 | "/home/xyz/incdbscan/incrementaldbscan.py:104: IncrementalDBSCANWarning: Object at position 9 was not deleted because there is no such object in the object set.\n", 479 | " warnings.warn(\n" 480 | ] 481 | }, 482 | { 483 | "data": { 484 | "text/plain": [ 485 | "" 486 | ] 487 | }, 488 | "execution_count": 19, 489 | "metadata": {}, 490 | "output_type": "execute_result" 491 | } 492 | ], 493 | "source": [ 494 | "clusterer.delete(X_1[:10])" 495 | ] 496 | }, 497 | { 498 | "cell_type": "code", 499 | "execution_count": null, 500 | "metadata": {}, 501 | "outputs": [], 502 | "source": [] 503 | } 504 | ], 505 | "metadata": { 506 | "kernelspec": { 507 | "display_name": "Python 3 (ipykernel)", 508 | "language": "python", 509 | "name": "python3" 510 | }, 511 | "language_info": { 512 | "codemirror_mode": { 513 | "name": "ipython", 514 | "version": 3 515 | }, 516 | "file_extension": ".py", 517 | "mimetype": "text/x-python", 518 | "name": "python", 519 | "nbconvert_exporter": "python", 520 | "pygments_lexer": "ipython3", 521 | "version": "3.10.12" 522 | } 523 | }, 524 | "nbformat": 4, 525 | "nbformat_minor": 4 526 | } 527 | -------------------------------------------------------------------------------- /notes/notes-on-paper.md: -------------------------------------------------------------------------------- 1 | # Notes on the IncrementalDBSCAN paper 2 | The work by Ester et al. 1998 lays the groundwork for this implementation of IncrementalDBSCAN. However, some parts of the algorithm are not covered in the paper. Here, these holes will be identified, and solutions are proposed to fill them. 3 | 4 | Notations used: 5 | - *D*: the set of data objects. 6 | - *NEps(p)*: the set of all objects that are in the *Eps*-neighborhood of *p*. 7 | - *UpdSeedIns*, the set of update seeds after insertion, is defined in *Definition 7* as the set of core objects in the *Eps*-neighborhood of those objects that gain their core object property as a result of the insertion into *D*. 8 | - *UpdSeedDel*, the set of update seeds after deletion, is defined in *Definition 7* as the set of core objects in the *Eps*-neighborhood of those objects that lose their core object property as a result of the deletion from *D*. 9 | 10 | ## Absorption when *UpdSeedIns* is empty 11 | Let's suppose that cluster *C* is already established, and a new object *p* is inserted in the *Eps*-neighborhood of a core object *c* of cluster *C*. Additionally, suppose that there are not enough objects in *NEps(p)* for *p* to become a core object and that no other objects become core objects due to the insertion. 12 | 13 | Now, how should *p* be handled? 14 | 15 | Since there are no new core objects after the insertion, *UpdSeedIns* is empty. According to *Section 4.2*, _"if *UpdSeedIns* is empty [...] then *p* is a noise object."_ But we also know that *p* is in *NEps(c)*, and according to *Definition 4* this means that it should be assigned to cluster *C*. There is clearly a contradiction here. 16 | 17 | **Solution**: In this implementation, even if *UpdSeedIns* is empty, *p* is assigned to cluster *C* if *c* is a core object of cluster *C* and *p* is in *NEps(c)*. 18 | 19 | ## Simultaneous creations, absorptions and merges 20 | In *Section 4.2*, cases of creation, absorption and merge are presented. These are indeed essential building blocks of IncrementalDBSCAN. However, the paper fails to mention that these events can happen simultaneously. 21 | 22 | Let's see an example. Suppose we have a 1 dimensional data set with 6 objects (*a*, *b*, *c*, *x*, *y*, *z*) as illustrated in the following block. The coordinates of the objects are as noted below their names. 23 |
24 | - - - c - b - a - - - - - - - x - y - z - - -
25 |      -4  -3  -2               2   3   4      
26 | 
27 | 28 | If we apply IncrementalDBSCAN to the data set with *Eps*=2 and *MinPts*=4, no clusters are created since none of the objects have an *Eps*-neighborhood that contain at least 4 objects. That is, all objects are noise objects. 29 | 30 | We now insert object *p* at position 0. 31 |
32 | - - - c - b - a - - - p - - - x - y - z - - -
33 |      -4  -3  -2       0       2   3   4      
34 | 
35 | 36 | After the insertion, both *NEps(a)* and *NEps(x)* contain 4 objects, so *a* and *x* become core objects. *UpdSeedIns* then contains the new core objects, *a* and *x*. According to the paper if _"UpdSeedIns contains only core objects which did not belong to a cluster before the insertion of p, i.e. they were noise objects or equal to p, [...] a new cluster containing these noise objects as well as p is created."_ 37 | 38 | Here *UpdSeedIns* contains only new core objects (*a* and *x*) but all 7 of the objects cannot be part of one cluster, since not all objects would be density-reachable from any other object in the cluster (because, e.g., *a* is not directly density-reachable from *p*). Thus, the definition of a cluster (*Definition 4*) wouldn't hold. This is contradictory to the above quote from *Section 4.2*. 39 | 40 | Analogous examples can be constructed for absorptions and merges. E.g., a creation and an absorption can happen at the same time, or even two merges can. But the paper doesn't cover these cases. 41 | 42 | **Solution**: *UpdSeedIns* should be broken down to components in which each object is density-connected to any other object in the component. The rules of creation, absorption and merge should be applied not to *UpdSeedIns* as a whole but to each component individually. 43 | 44 | ## Extended definition of *UpdSeedDel* 45 | 46 | The point of defining *UpdSeedDel* is to take the first step towards finding all objects in the whole object set that eventually might be affected by a deletion. *UpdSeedDel* contains the _"seed objects for the update"_. 47 | 48 | Let's take the following object set *D* of 7 one dimensional objects (*a*, *b*, *c*, *p*, *x*, *y*, *z*). The coordinates of the objects are as noted below their names. 49 |
50 | - - - c - b - a - - - p - - - x - y - z - - -
51 |      -4  -3  -2       0       2   3   4      
52 | 
53 | 54 | Suppose we apply IncrementalDBSCAN to the objects with *MinPts*=3 and *Eps*=2. As a result, all objects belong to a single cluster. 55 | 56 | Now suppose we delete *p*. Following *Definition 7* in the paper, *UpdSeedDel* would be empty, since there is no object that is core in *D* but not in *D* \ {*p*}. Thus, according to the definition, there are no seed objects for the update. 57 | 58 | This is in conflict with the results of the deletion, in which there are now two clusters of objects, as can be seen below. Thus, there was indeed a need for cluster membership update. 59 |
60 | - - - c - b - a - - - - - - - x - y - z - - -
61 |      -4  -3  -2               2   3   4      
62 | 
63 | 64 | **Solution**: in this implementation, the definition of *UpdSeedDel* is extended to cover such cases. It is (informally) the set of core objects in the *Eps*-neighborhood of either (1) those objects that lose their core object property as a result of the deletion of *p* or (2) *p* itself. 65 | 66 | ## Updates needed when *UpdSeedDel* is empty 67 | 68 | According to *Section 4.3* of the paper, when during the deletion of an object *p* if _"UpdSeedDel is empty [...] then p is deleted from D and eventually other objects in NEps(p) change from a former cluster C to noise"._ 69 | 70 | However, consider there are two core objects in *D*, *p* and *q*, not in the *Eps*-neighborhood of each other. They are of different clusters, *C1* and *C2*, respectively. And suppose there is an object *b* that is not core and is in both *NEps(p)* and *NEps(q)* (but not in *NEps(r)* for of any other object *r*). In such cases *b* is either in cluster *C1* or *C2*. In this example assume it is in *C1*. 71 | 72 | We now delete *p* from *D*. *UpdSeedDel* is empty because there are no core objects in the *Eps*-neighborhood of objects that lost their core property. *b* is then no longer in *C1* (as there is no object to keep it there) but does not become noise. Instead, because it is in *NEps(q)* it should be assigned to *C2*, which goes against the description in the paper. 73 | 74 | **Solution**: in this implementation whenever an object loses its cluster membership it is checked first if it should be reassigned to another cluster. Only if it is not in the *Eps*-neighborhood of any other core objects it becomes noise. 75 | 76 | ## Simultaneous splits 77 | 78 | When the paper, in *Section 4.3* (_"potential Split"_), describes the splitting logic that happens after an object *p* is deleted, it says this is when *UpdSeedDel* is not empty and the objects in it _"belonged to exactly one cluster [...] before the deletion of p."_ 79 | 80 | Take the following two dimensional object set *D* as example. There are several objects, most of them marked with a star, and 3 of them with a letter: *p*, *b*, and *q*. With the left and bottom axes one can see the coordinates of the objects. 81 | 82 |
83 |  1   *  *  *     q     *  *  *
84 | 
85 |  0               b
86 | 
87 | -1   *  *  *     p     *  *  *
88 |     -2    -1     0     1     2
89 | 
90 | 91 | When we cluster these objects according to DBSCAN with *MinPts*=4 and *Eps*=1, two clusters emerge. The first cluster consists of the objects on the *y*=-1 line, while the second one with objects on the line *y*=1. Object *b*, since there are less than *MinPts* objects in *NEps(b)*, is not a core object itself, but it belongs to either one of the clusters as a border object. 92 | 93 | What happens when we delete *b* from *D*? As a result, both *q* and *p* lose their core property. According to the definition of *UpdSeedDel*, the core objects in the neighborhood of *p* and *q*, that is, the objects marked with stars next to them, will be in *UpdSeedDel*. These objects belonged to two clusters, not _"exactly one cluster [...] before the deletion of p"_, as the paper states. The paper misses a point here. 94 | 95 | **Solution**: In this case, this implementation follows the logic of DBSCAN and reaches the conclusion of what would happen if DBSCAN was applied to *D* after the deletion of *p*. That is, four clusters are formed, two at the top and two at the bottom. So two splits need happen at the same time: both of the bottom and the top cluster break down into two smaller clusters. 96 | -------------------------------------------------------------------------------- /profiling.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | from line_profiler import LineProfiler 6 | 7 | from incdbscan import IncrementalDBSCAN 8 | from incdbscan._bfscomponentfinder import BFSComponentFinder 9 | from incdbscan._deleter import Deleter 10 | from incdbscan._inserter import Inserter 11 | from incdbscan._labels import LabelHandler 12 | from incdbscan._neighbor_searcher import NeighborSearcher 13 | from incdbscan._object import Object 14 | from incdbscan._objects import Objects 15 | from incdbscan.tests.testutils import ( 16 | read_chameleon_data, 17 | read_handl_data 18 | ) 19 | 20 | 21 | BASE_PATH = Path(__file__).parent 22 | DATA_PATH = BASE_PATH / 'incdbscan' / 'tests' / 'data' 23 | 24 | 25 | def test1(): 26 | data = read_handl_data() 27 | 28 | algo = IncrementalDBSCAN(eps=1) 29 | algo.insert(data) 30 | algo.delete(data) 31 | 32 | 33 | def test2(): 34 | data = read_chameleon_data()[:2000] 35 | 36 | algo = IncrementalDBSCAN(eps=10) 37 | algo.insert(data) 38 | algo.delete(data) 39 | 40 | 41 | def print_profile(test, tag=''): 42 | profiler = LineProfiler() 43 | profiler.add_module(Inserter) 44 | profiler.add_module(Deleter) 45 | # profiler.add_module(BFSComponentFinder) 46 | # profiler.add_module(Object) 47 | # profiler.add_module(Objects) 48 | # profiler.add_module(LabelHandler) 49 | # profiler.add_module(NeighborSearcher) 50 | 51 | wrapper = profiler(test) 52 | wrapper() 53 | 54 | timestamp = str(datetime.now())[:19] 55 | filename = f'{timestamp}_{test.__name__}{tag}.txt' 56 | profile_path = BASE_PATH / 'profiling' / filename 57 | 58 | with open(profile_path, 'w') as f: 59 | profiler.print_stats(stream=f) 60 | 61 | 62 | if __name__ == "__main__": 63 | tag = '_' + sys.argv[1] if len(sys.argv) > 1 else '' 64 | for test in [test1, test2]: 65 | print(f'{datetime.now()} Creating profile for {test.__name__} ...') 66 | print_profile(test, tag) 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Arpad Fulop "] 3 | description = "Implementation of IncrementalDBSCAN clustering." 4 | license = "BSD-3-Clause" 5 | name = "incdbscan" 6 | version = "0.3.0" 7 | readme = "README.md" 8 | homepage = "https://github.com/DataOmbudsman/incdbscan" 9 | repository = "https://github.com/DataOmbudsman/incdbscan" 10 | keywords = [ 11 | "clustering", 12 | "incremental clustering" 13 | ] 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: BSD License", 19 | 'Operating System :: OS Independent', 20 | 'Programming Language :: Python :: 3.8', 21 | 'Programming Language :: Python :: 3.9', 22 | 'Programming Language :: Python :: 3.10', 23 | 'Programming Language :: Python :: 3.11', 24 | 'Programming Language :: Python :: 3.12', 25 | 'Programming Language :: Python :: 3.13', 26 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | numpy = "^2.0.0" 31 | python = ">=3.9.0,<4.0" 32 | rustworkx = "^0.15.0" 33 | scikit-learn = "^1.5.0" 34 | sortedcontainers = "^2.4.0" 35 | xxhash = "^3.5.0" 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | isort = "^6.0.1" 39 | jupyterlab = "^4.4.0" 40 | line-profiler = "^4.2.0" 41 | matplotlib = "^3.9.4" 42 | pandas = "^2.2.3" 43 | pylint = "^3.3.6" 44 | pytest = "^8.3.5" 45 | requests = "^2.32.3" 46 | tqdm = "^4.67.1" 47 | 48 | [build-system] 49 | requires = ["poetry_core >= 1.8.1"] 50 | build-backend = "poetry.core.masonry.api" 51 | 52 | [tool.isort] 53 | known_first_party = ["testutils"] 54 | profile = "pycharm" 55 | 56 | [tool.pytest.ini_options] 57 | markers = [ 58 | "slow: marks tests as slow (deselect with '-m \"not slow\"')" 59 | ] --------------------------------------------------------------------------------