Repository: NiklasMelton/AdaptiveResonanceLib (language: python)

Issue:
Update joss paper



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/citation-update.yml b/.github/workflows/citation-update.yml
new file mode 100644
index 0000000..e8e720d
--- /dev/null
+++ b/.github/workflows/citation-update.yml
@@ -0,0 +1,36 @@
+name: Update CITATION.cff on Release
+on:
+  release:
+    types: [published]
+
+jobs:
+  update_citation:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+
+    - name: Get Release Data
+      id: release_info
+      run: |
+        echo "RELEASE_TAG=$(echo ${GITHUB_REF#refs/tags/})" >> $GITHUB_ENV
+
+    - name: Fetch DOI from Zenodo
+      run: |
+        # Replace with actual API request or data fetch logic
+        # Example command to fetch the DOI for the latest release via Zenodo API
+        curl -s "https://zenodo.org/api/records?query=AdaptiveResonanceLib" | jq '.hits.hits[0].doi' > doi.txt
+
+    - name: Update CITATION.cff
+      run: |
+        DOI=$(cat doi.txt)
+        sed -i "s/doi: .*/doi: \"$DOI\"/" CITATION.cff
+        sed -i "s/version: .*/version: \"$RELEASE_TAG\"/" CITATION.cff
+
+    - name: Commit updated CITATION.cff
+      run: |
+        git config --global user.name "github-actions[bot]"
+        git config --global user.email "github-actions[bot]@users.noreply.github.com"
+        git add CITATION.cff
+        git commit -m "Update CITATION.cff with DOI and version for release $RELEASE_TAG"
+        git push origin main
diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml
new file mode 100644
index 0000000..1d7dc44
--- /dev/null
+++ b/.github/workflows/draft-pdf.yml
@@ -0,0 +1,24 @@
+name: Draft PDF
+on: [push]
+
+jobs:
+  paper:
+    runs-on: ubuntu-latest
+    name: Paper Draft
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build draft PDF
+        uses: openjournals/openjournals-draft-action@master
+        with:
+          journal: joss
+          # This should be the path to the paper within your repo.
+          paper-path: paper.md
+      - name: Upload
+        uses: actions/upload-artifact@v4
+        with:
+          name: paper
+          # This is the output path where Pandoc will write the compiled
+          # PDF. Note, this should be the same directory as the input
+          # paper.md
+          path: paper.pdf
diff --git a/.github/workflows/pre_commit_checks.yml b/.github/workflows/pre_commit_checks.yml
new file mode 100644
index 0000000..4b32f6a
--- /dev/null
+++ b/.github/workflows/pre_commit_checks.yml
@@ -0,0 +1,45 @@
+name: Pre-commit checks
+
+on:
+  pull_request:
+  push:
+    branches:
+      - develop
+      - main
+
+jobs:
+  pre-commit:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.9'  # Adjust this to your desired Python version
+
+      - name: Install Poetry
+        run: |
+          curl -sSL https://install.python-poetry.org | python3 -
+
+      - name: Install dependencies
+        run: |
+          poetry install --with dev
+
+      - name: Install pre-commit
+        run: |
+          poetry run pip install pre-commit
+
+      - name: Fetch the base branch
+        run: |
+          git fetch origin ${GITHUB_BASE_REF}:${GITHUB_BASE_REF}
+        env:
+          GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref }}
+
+      - name: Run pre-commit on changed files
+        run: |
+          poetry run pre-commit run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD
+        env:
+          GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..e6bd3d7
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,47 @@
+# .pre-commit-config.yaml
+
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.5.0
+    hooks:
+      - id: check-yaml
+      - id: debug-statements
+      - id: end-of-file-fixer
+      - id: trailing-whitespace
+
+  - repo: local
+    hooks:
+      - id: docformatter
+        name: docformatter
+        entry: docformatter
+        language: python
+        types: [python]
+        additional_dependencies: [docformatter==1.7.5]
+        args:
+          - --black
+          - --style=numpy
+          - --blank
+          - --in-place
+
+  - repo: https://github.com/psf/black
+    rev: 23.9.1  # Use the latest stable version
+    hooks:
+      - id: black
+
+  - repo: https://github.com/PyCQA/flake8
+    rev: 6.1.0  # Use the latest stable version
+    hooks:
+      - id: flake8
+        args: [--max-line-length=88, '--ignore=D205,D400,D105,E731,W503,D401,E203,D209']
+        additional_dependencies: [flake8-docstrings]
+
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v1.5.1
+    hooks:
+      - id: mypy
+        args:
+          - --ignore-missing-imports
+          - --exclude=artlib/cvi/
+          - --follow-imports=silent
+
+exclude: ^(unit_tests/|scripts/|artlib/experimental/|examples/|docs/|artlib/cvi/)
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..8daf6a8
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,19 @@
+cff-version: 1.2.0
+message: "If you use this software, please cite it as below."
+title: "AdaptiveResonanceLib"
+version: "0.1.2"
+doi: "10.5281/zenodo.9999999"
+authors:
+  - family-names: "Melton"
+    given-names: "Niklas"
+    orcid: "https://orcid.org/0000-0001-9625-7086"
+date-released: 2024-10-03
+url: "https://github.com/NiklasMelton/AdaptiveResonanceLib"
+repository-code: "https://github.com/NiklasMelton/AdaptiveResonanceLib"
+license: "MIT"
+keywords:
+  - "adaptive resonance theory"
+  - "ART"
+  - "machine learning"
+  - "clustering"
+  - "neural networks"
diff --git a/CITATIONS.txt b/CITATIONS.txt
index 92a0339..ff9efa6 100644
--- a/CITATIONS.txt
+++ b/CITATIONS.txt
@@ -366,4 +366,4 @@ Yava ̧s, M., & Alpaslan, F. N. (2012). Hierarchical behavior categorization usi
 
 Zadeh, L. A. (1965). Fuzzy sets. Information and Control, 8, 338 – 353. doi:10.1016/S0019-9958(65) 90241-X.
 
-Brito da Silva LE, Rayapati N, Wunsch DC. iCVI-ARTMAP: Using Incremental Cluster Validity Indices and Adaptive Resonance Theory Reset Mechanism to Accelerate Validation and Achieve Multiprototype Unsupervised Representations. IEEE Trans Neural Netw Learn Syst. 2023 Dec;34(12):9757-9770. doi: 10.1109/TNNLS.2022.3160381. Epub 2023 Nov 30. PMID: 35353707.
\ No newline at end of file
+Brito da Silva LE, Rayapati N, Wunsch DC. iCVI-ARTMAP: Using Incremental Cluster Validity Indices and Adaptive Resonance Theory Reset Mechanism to Accelerate Validation and Achieve Multiprototype Unsupervised Representations. IEEE Trans Neural Netw Learn Syst. 2023 Dec;34(12):9757-9770. doi: 10.1109/TNNLS.2022.3160381. Epub 2023 Nov 30. PMID: 35353707.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 65056db..6158420 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,9 +38,9 @@ If you're ready to contribute, here's how you can do it:
 
 5. **Run the tests** to ensure your changes don't break existing functionality.
 
-6. **Update the documentation** if you're adding new features or changing existing behavior. 
+6. **Update the documentation** if you're adding new features or changing existing behavior.
 
-7. **Submit a pull request**. Include a clear description of the changes and reference any related issues. 
+7. **Submit a pull request**. Include a clear description of the changes and reference any related issues.
 
 ## Pull Request Process
 
diff --git a/README.md b/README.md
index 7a011be..70a1101 100644
--- a/README.md
+++ b/README.md
@@ -3,105 +3,209 @@
 
 Welcome to AdaptiveResonanceLib, a comprehensive and modular Python library for Adaptive Resonance Theory (ART) algorithms. Based on scikit-learn, our library offers a wide range of ART models designed for both researchers and practitioners in the field of machine learning and neural networks. Whether you're working on classification, clustering, or pattern recognition, AdaptiveResonanceLib provides the tools you need to implement ART algorithms efficiently and effectively.
 
+<!-- START available_models -->
 ## Available Models
 
 AdaptiveResonanceLib includes implementations for the following ART models:
 
 - #### Elementary Clustering
-    - ART1
-    - ART2
-    - Bayesian ART
-    - Gaussian ART
-    - Hypersphere ART
-    - Ellipsoidal ART
-    - Fuzzy ART
-    - Quadratic Neuron ART
-    - Dual Vigilance ART
-    - Gram ART
-- #### Metric Informed 
-    - CVI ART  
-    - iCVI Fuzzy ART  
+    - [ART1](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.ART1)
+    - [ART2](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.ART2A)
+    - [Bayesian ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.BayesianART)
+    - [Gaussian ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.GaussianART)
+    - [Hypersphere ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.HypersphereART)
+    - [Ellipsoid ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.EllipsoidART)
+    - [Fuzzy ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.FuzzyART)
+    - [Quadratic Neuron ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.QuadraticNeuronART)
+- #### Metric Informed
+    - [CVI ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.CVIART)
+    - [iCVI Fuzzy ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.iCVIFuzzyART)
 - #### Topological
-    - Topo ART
+    - [Topo ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.TopoART)
+    - [Dual Vigilance ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.DualVigilanceART)
 - #### Classification
-    - Simple ARTMAP
+    - [Simple ARTMAP](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.SimpleARTMAP)
 - #### Regression
-    - ARTMAP
-    - Fusion ART
+    - [ARTMAP](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.ARTMAP)
+    - [Fusion ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.FusionART)
 - #### Hierarchical
-    - DeepARTMAP
-    - SMART
+    - [DeepARTMAP](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.DeepARTMAP)
+    - [SMART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.SMART)
 - #### Data Fusion
-    - Fusion ART
+    - [Fusion ART](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.FusionART)
 - #### Reinforcement Learning
-    - FALCON
-  
+    - [FALCON](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.FALCON)
+    - [TD-FALCON](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.html#artlib.TD_FALCON)
 - #### Biclustering
-    - Biclustering ARTMAP
+    - [Biclustering ARTMAP](https://adaptiveresonancelib.readthedocs.io/en/latest/artlib.biclustering.html#artlib.biclustering.BARTMAP.BARTMAP)
 
+<!-- END available_models -->
+
+<!-- START comparison_of_elementary_models -->
 ## Comparison of Elementary Models
-![Comparison of Elementary Images](./img/comparison_of_elementary_methods.jpg?raw=true")
 
+[comment]: <> (![Comparison of Elementary Images]&#40;https://github.com/NiklasMelton/AdaptiveResonanceLib/raw/main/docs/_static/comparison_of_elementary_methods.jpg?raw=true"&#41;)
+![Comparison of Elementary Images](https://github.com/NiklasMelton/AdaptiveResonanceLib/raw/main/img/comparison_of_elementary_methods.jpg?raw=true")
+<!-- END comparison_of_elementary_models -->
+
+<!-- START installation -->
 ## Installation
 
 To install AdaptiveResonanceLib, simply use pip:
 
-[comment]: <> (```bash)
-
-[comment]: <> (pip install AdaptiveResonanceLib)
+```bash
+pip install artlib
+```
 
-[comment]: <> (```)
+Or to install directly from the most recent source:
 
 ```bash
-pip install artlib
+pip install git+https://github.com/NiklasMelton/AdaptiveResonanceLib.git@develop
 ```
 
 Ensure you have Python 3.9 or newer installed.
+<!-- END installation -->
 
+<!-- START quick-start -->
 ## Quick Start
 
-Here's a quick example of how to use AdaptiveResonanceLib with the Fuzzy ART model:
+Here are some quick examples to get you started with AdaptiveResonanceLib:
+
+### Clustering Data with the Fuzzy ART model
 
 ```python
 from artlib import FuzzyART
 import numpy as np
 
 # Your dataset
-train_X = np.array([...])
+train_X = np.array([...]) # shape (n_samples, n_features)
 test_X = np.array([...])
 
 # Initialize the Fuzzy ART model
 model = FuzzyART(rho=0.7, alpha = 0.0, beta=1.0)
 
+# Prepare Data
+train_X_prep = model.prepare_data(train_X)
+test_X_prep = model.prepare_data(test_X)
+
+# Fit the model
+model.fit(train_X_prep)
+
+# Predict data labels
+predictions = model.predict(test_X_prep)
+```
+
+### Fitting a Classification Model with SimpleARTMAP
+
+```python
+from artlib import GaussianART, SimpleARTMAP
+import numpy as np
+
+# Your dataset
+train_X = np.array([...]) # shape (n_samples, n_features)
+train_y = np.array([...]) # shape (n_samples, ), must be integers
+test_X = np.array([...])
+
+# Initialize the Gaussian ART model
+sigma_init = np.array([0.5]*train_X.shape[1]) # variance estimate for each feature
+module_a = GaussianART(rho=0.0, sigma_init=sigma_init)
+
+# Initialize the SimpleARTMAP model
+model = SimpleARTMAP(module_a=module_a)
+
+# Prepare Data
+train_X_prep = model.prepare_data(train_X)
+test_X_prep = model.prepare_data(test_X)
+
 # Fit the model
-model.fit(train_X)
+model.fit(train_X_prep, train_y)
 
-# Predict new data points
-predictions = model.predict(test_X)
+# Predict data labels
+predictions = model.predict(test_X_prep)
 ```
 
-Replace `params` with the parameters appropriate for your use case.
+### Fitting a Regression Model with FusionART
+
+```python
+from artlib import FuzzyART, HypersphereART, FusionART
+import numpy as np
+
+# Your dataset
+train_X = np.array([...]) # shape (n_samples, n_features_X)
+train_y = np.array([...]) # shape (n_samples, n_features_y)
+test_X = np.array([...])
 
+# Initialize the Fuzzy ART model
+module_x = FuzzyART(rho=0.0, alpha = 0.0, beta=1.0)
+
+# Initialize the Hypersphere ART model
+r_hat = 0.5*np.sqrt(train_X.shape[1]) # no restriction on hyperpshere size
+module_y = HypersphereART(rho=0.0, alpha = 0.0, beta=1.0, r_hat=r_hat)
+
+# Initialize the FusionARTMAP model
+gamma_values = [0.5, 0.5] # eqaul weight to both channels
+channel_dims = [
+  2*train_X.shape[1], # fuzzy ART complement codes data so channel dim is 2*n_features
+  train_y.shape[1]
+]
+model = FusionART(
+  modules=[module_x, module_y],
+  gamma_values=gamma_values,
+  channel_dims=channel_dims
+)
+
+# Prepare Data
+train_Xy = model.join_channel_data(channel_data=[train_X, train_y])
+train_Xy_prep = model.prepare_data(train_Xy)
+test_Xy = model.join_channel_data(channel_data=[train_X], skip_channels=[1])
+test_Xy_prep = model.prepare_data(test_Xy)
+
+# Fit the model
+model.fit(train_X_prep, train_y)
+
+# Predict y-channel values
+pred_y = model.predict_regression(test_Xy_prep, target_channels=[1])
+```
+
+<!-- END quick-start -->
+
+<!-- START documentation -->
 ## Documentation
 
-For more detailed documentation, including the full list of parameters for each model, visit our [documentation page](https://github.com/NiklasMelton/AdaptiveResonanceLib).
+For more detailed documentation, including the full list of parameters for each model, visit our [Read the Docs page](https://adaptiveresonancelib.readthedocs.io/en/latest/index.html).
+<!-- END documentation -->
 
+<!-- START examples -->
 ## Examples
 
-For examples of how to use each model in AdaptiveResonanceLib, check out the `/examples` directory in our repository.
+For examples of how to use each model in AdaptiveResonanceLib, check out the [`/examples`](https://github.com/NiklasMelton/AdaptiveResonanceLib/tree/develop/examples) directory in our repository.
+<!-- END examples -->
 
+<!-- START contributing -->
 ## Contributing
 
 We welcome contributions to AdaptiveResonanceLib! If you have suggestions for improvements, or if you'd like to add more ART models, please see our `CONTRIBUTING.md` file for guidelines on how to contribute.
 
 You can also join our [Discord server](https://discord.gg/E465HBwEuN) and participate directly in the discussion.
+<!-- END contributing -->
 
+<!-- START license -->
 ## License
 
-AdaptiveResonanceLib is open source and available under the MIT license. See the `LICENSE` file for more info.
+AdaptiveResonanceLib is open source and available under the MIT license. See the [`LICENSE`](https://github.com/NiklasMelton/AdaptiveResonanceLib/blob/develop/LICENSE) file for more info.
+<!-- END license -->
 
+<!-- START contact -->
 ## Contact
 
 For questions and support, please open an issue in the GitHub issue tracker or message us on our [Discord server](https://discord.gg/E465HBwEuN). We'll do our best to assist you.
 
 Happy Modeling with AdaptiveResonanceLib!
+<!-- END contact -->
+
+<!-- START citation -->
+## Citing this Repository
+If you use this project in your research, please cite it as:
+
+Melton, N. (2024). AdaptiveResonanceLib (Version 0.1.2)
+<!-- END citation -->
diff --git a/artlib/__init__.py b/artlib/__init__.py
index 2dfdd95..aee1c79 100644
--- a/artlib/__init__.py
+++ b/artlib/__init__.py
@@ -1,12 +1,32 @@
+"""Adaptive Resonance Theory (ART) is a cognitive and neural network model that explains
+how the brain learns to recognize patterns while maintaining stability in the face of
+new, potentially conflicting information. ART networks are known for their ability to
+perform unsupervised learning and adaptively categorize data without forgetting
+previously learned patterns, a feature known as "plasticity-stability balance.".
+
+The ART modules provided here support classification, clustering, and reinforcement
+learning tasks by dynamically adjusting to incoming data streams. They also offer
+advanced capabilities, including hierarchical clustering, topological clustering, data
+fusion, and regression, enabling flexible exploration of complex data structures.
+
+`Adaptive Resonance Theory <https://en.wikipedia.org/wiki/Adaptive_resonance_theory>`_
+
+"""
+
+
 from artlib.common.BaseART import BaseART
 from artlib.common.BaseARTMAP import BaseARTMAP
-from artlib.common.utils import normalize, compliment_code, de_compliment_code, de_normalize
+from artlib.common.utils import (
+    normalize,
+    compliment_code,
+    de_compliment_code,
+    de_normalize,
+)
 from artlib.common.VAT import VAT
 
 from artlib.elementary.ART1 import ART1
 from artlib.elementary.ART2 import ART2A
 from artlib.elementary.BayesianART import BayesianART
-from artlib.elementary.DualVigilanceART import DualVigilanceART
 from artlib.elementary.EllipsoidART import EllipsoidART
 from artlib.elementary.GaussianART import GaussianART
 from artlib.elementary.FuzzyART import FuzzyART
@@ -16,7 +36,8 @@
 from artlib.cvi.iCVIFuzzyArt import iCVIFuzzyART
 from artlib.cvi.CVIART import CVIART
 
-from artlib.supervised.ARTMAP import ARTMAP, SimpleARTMAP
+from artlib.supervised.ARTMAP import ARTMAP
+from artlib.supervised.SimpleARTMAP import SimpleARTMAP
 
 from artlib.hierarchical.SMART import SMART
 from artlib.hierarchical.DeepARTMAP import DeepARTMAP
@@ -28,12 +49,16 @@
 from artlib.biclustering.BARTMAP import BARTMAP
 
 from artlib.topological.TopoART import TopoART
+from artlib.topological.DualVigilanceART import DualVigilanceART
 
 __all__ = [
     "BaseART",
     "BaseARTMAP",
     "normalize",
     "compliment_code",
+    "de_compliment_code",
+    "de_normalize",
+    "VAT",
     "ART1",
     "ART2A",
     "BayesianART",
@@ -51,5 +76,7 @@
     "FusionART",
     "BARTMAP",
     "iCVIFuzzyART",
-    "CVIART"
-]
\ No newline at end of file
+    "CVIART",
+    "FALCON",
+    "TD_FALCON",
+]
diff --git a/artlib/biclustering/BARTMAP.py b/artlib/biclustering/BARTMAP.py
index c8801fe..57b5b79 100644
--- a/artlib/biclustering/BARTMAP.py
+++ b/artlib/biclustering/BARTMAP.py
@@ -1,14 +1,13 @@
-"""
-Xu, R., & Wunsch II, D. C. (2011).
-BARTMAP: A viable structure for biclustering.
-Neural Networks, 24, 709–716. doi:10.1016/j.neunet.2011.03.020.
-
-Xu, R., Wunsch II, D. C., & Kim, S. (2012).
-Methods and systems for biclustering algorithm.
-U.S. Patent 9,043,326 Filed January 28, 2012,
-claiming priority to Provisional U.S. Patent Application,
-January 28, 2011, issued May 26, 2015.
-"""
+"""BARTMAP :cite:`xu2011bartmap`, :cite:`xu2012biclustering`."""
+# Xu, R., & Wunsch II, D. C. (2011).
+# BARTMAP: A viable structure for biclustering.
+# Neural Networks, 24, 709–716. doi:10.1016/j.neunet.2011.03.020.
+#
+# Xu, R., Wunsch II, D. C., & Kim, S. (2012).
+# Methods and systems for biclustering algorithm.
+# U.S. Patent 9,043,326 Filed January 28, 2012,
+# claiming priority to Provisional U.S. Patent Application,
+# January 28, 2011, issued May 26, 2015.
 
 import numpy as np
 from typing import Optional
@@ -18,17 +17,40 @@
 from sklearn.base import BaseEstimator, BiclusterMixin
 from scipy.stats import pearsonr
 
+
 class BARTMAP(BaseEstimator, BiclusterMixin):
-    rows_: np.ndarray #bool
-    columns_: np.ndarray #bool
+    """BARTMAP for Biclustering.
+
+    This class implements BARTMAP as first published in:
+    :cite:`xu2011bartmap`.
+
+    .. # Xu, R., & Wunsch II, D. C. (2011).
+    .. # BARTMAP: A viable structure for biclustering.
+    .. # Neural Networks, 24, 709–716. doi:10.1016/j.neunet.2011.03.020.
+
+    BARTMAP accepts two instantiated :class:`~artlib.common.BaseART.BaseART` modules
+    `module_a` and `module_b` which cluster the rows (samples) and columns (features)
+    respectively. The features are clustered independently, but the samples are
+    clustered by considering samples already within a row cluster as well as the
+    candidate sample and enforcing a minimum correlation within the subset of
+    features belonging to at least one of the feature clusters.
+
+    """
+
+    rows_: np.ndarray  # bool
+    columns_: np.ndarray  # bool
 
     def __init__(self, module_a: BaseART, module_b: BaseART, eta: float):
-        """
+        """Initialize the BARTMAP model.
 
-        Parameters:
-        - module_a: a-side ART module
-        - module_b: b-side ART module
-        - eta: minimum correlation
+        Parameters
+        ----------
+        module_a : BaseART
+            The instantiated ART module used for clustering the rows (samples).
+        module_b : BaseART
+            The instantiated ART module used for clustering the columns (features).
+        eta : float
+            The minimum Pearson correlation required for row clustering.
 
         """
         params: dict = {"eta": eta}
@@ -42,10 +64,12 @@ def __getattr__(self, key):
             return self.params[key]
         else:
             # If the key is not in params, raise an AttributeError
-            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'")
+            raise AttributeError(
+                f"'{type(self).__name__}' object has no attribute '{key}'"
+            )
 
     def __setattr__(self, key, value):
-        if key in self.__dict__.get('params', {}):
+        if key in self.__dict__.get("params", {}):
             # If key is in params, set its value
             self.params[key] = value
         else:
@@ -53,13 +77,18 @@ def __setattr__(self, key, value):
             super().__setattr__(key, value)
 
     def get_params(self, deep: bool = True) -> dict:
-        """
+        """Get parameters for this estimator.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+        Parameters
+        ----------
+        deep : bool, default=True
+            If True, return the parameters for this estimator and contained subobjects
+            that are estimators.
 
-        Returns:
-            Parameter names mapped to their values.
+        Returns
+        -------
+        dict
+            Dictionary of parameter names mapped to their values.
 
         """
         out = self.params
@@ -76,15 +105,19 @@ def get_params(self, deep: bool = True) -> dict:
     def set_params(self, **params):
         """Set the parameters of this estimator.
 
-        Specific redefinition of sklearn.BaseEstimator.set_params for ART classes
+        Specific redefinition of `sklearn.BaseEstimator.set_params` for ART classes.
 
-        Parameters:
-        - **params : Estimator parameters.
+        Parameters
+        ----------
+        **params : dict
+            Estimator parameters as keyword arguments.
 
-        Returns:
-        - self : estimator instance
-        """
+        Returns
+        -------
+        self : object
+            The estimator instance.
 
+        """
         if not params:
             # Simple optimization to gain speed (inspect is slow)
             return self
@@ -114,138 +147,205 @@ def set_params(self, **params):
         return self
 
     @staticmethod
-    def validate_params(params):
-        """
-        validate clustering parameters
+    def validate_params(params: dict):
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "eta" in params
         assert isinstance(params["eta"], float)
 
     @property
-    def column_labels_(self):
+    def column_labels_(self) -> np.ndarray:
+        """Cluster labels for the columns.
+
+        Returns
+        -------
+        column_labels_ : ndarray of shape (n_columns,)
+            Array of cluster labels assigned to each column.
+
+        """
         return self.module_b.labels_
 
     @property
-    def row_labels_(self):
+    def row_labels_(self) -> np.ndarray:
+        """Cluster labels for the rows.
+
+        Returns
+        -------
+        row_labels_ : ndarray of shape (n_rows,)
+            Array of cluster labels assigned to each row.
+
+        """
         return self.module_a.labels_
 
     @property
-    def n_row_clusters(self):
+    def n_row_clusters(self) -> int:
+        """Number of row clusters.
+
+        Returns
+        -------
+        n_row_clusters : int
+            The number of clusters for the rows.
+
+        """
         return self.module_a.n_clusters
 
     @property
-    def n_column_clusters(self):
+    def n_column_clusters(self) -> int:
+        """Number of column clusters.
+
+        Returns
+        -------
+        n_column_clusters : int
+            The number of clusters for the columns.
+
+        """
         return self.module_b.n_clusters
 
     def _get_x_cb(self, x: np.ndarray, c_b: int):
-        """
-        get the components of a vector belonging to a b-side cluster
+        """Get the components of a vector belonging to a b-side cluster.
 
-        Parameters:
-        - x: a sample vector
-        - c_b: b-side cluster label
+        Parameters
+        ----------
+        x : np.ndarray
+            A sample vector.
+        c_b : int
+            The b-side cluster label.
 
-        Returns:
-            x filtered to features belonging to the b-side cluster c_b
+        Returns
+        -------
+        np.ndarray
+            The sample vector `x` filtered to include only features belonging to
+            the b-side cluster `c_b`.
 
         """
         b_components = self.module_b.labels_ == c_b
         return x[b_components]
 
     @staticmethod
-    def _pearsonr(a: np.ndarray, b: np.ndarray):
-        """
-        get the correlation between two vectors
+    def _pearsonr(a: np.ndarray, b: np.ndarray) -> float:
+        """Get the Pearson correlation between two vectors.
 
-        Parameters:
-        - a: some vector
-        - b: some vector
+        Parameters
+        ----------
+        a : np.ndarray
+            A vector.
+        b : np.ndarray
+            Another vector.
 
-        Returns:
-            Pearson correlation
+        Returns
+        -------
+        float
+            The Pearson correlation between the two vectors `a` and `b`.
 
         """
         r, _ = pearsonr(a, b)
         return r
 
     def _average_pearson_corr(self, X: np.ndarray, k: int, c_b: int) -> float:
-        """
-        get the average correlation between for a sample for all features in cluster b
-
-        Parameters:
-        - X: data set A
-        - k: sample index
-        - c_b: b-side cluster to check
+        """Get the average Pearson correlation for a sample across all features in
+        cluster b.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset A.
+        k : int
+            The sample index.
+        c_b : int
+            The b-side cluster to check.
+
+        Returns
+        -------
+        float
+            The average Pearson correlation for the sample at index `k` across all
+            features in cluster `c_b`.
 
         """
         X_a = X[self.column_labels_ == c_b, :]
         if len(X_a) == 0:
-            raise ValueError("HERE")
-        X_k_cb = self._get_x_cb(X[k,:], c_b)
+            raise ValueError("X_a has length 0")
+        X_k_cb = self._get_x_cb(X[k, :], c_b)
         mean_r = np.mean(
-            [
-                self._pearsonr(X_k_cb, self._get_x_cb(x_a_l, c_b))
-                for x_a_l in X_a
-            ]
+            [self._pearsonr(X_k_cb, self._get_x_cb(x_a_l, c_b)) for x_a_l in X_a]
         )
 
         return float(mean_r)
 
     def validate_data(self, X_a: np.ndarray, X_b: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the data prior to clustering.
 
-        Parameters:
-        - X: data set A
-        - y: data set B
+        Parameters
+        ----------
+        X_a : np.ndarray
+            Dataset A, containing the samples.
+        X_b : np.ndarray
+            Dataset B, containing the features.
 
         """
         self.module_a.validate_data(X_a)
         self.module_b.validate_data(X_b)
 
-    def match_criterion_bin(self, X: np.ndarray, k: int, c_b: int, params: dict) -> bool:
-        """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - X: data set
-        - k: sample index
-        - c_b: b-side cluster to check
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster match criterion binary
+    def match_criterion_bin(
+        self, X: np.ndarray, k: int, c_b: int, params: dict
+    ) -> bool:
+        """Get the binary match criterion of the cluster.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        k : int
+            The sample index.
+        c_b : int
+            The b-side cluster to check.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        bool
+            Binary value indicating whether the cluster match criterion is met.
 
         """
         M = self._average_pearson_corr(X, k, c_b)
         return M >= self.params["eta"]
 
     def match_reset_func(
-            self,
-            i: np.ndarray,
-            w: np.ndarray,
-            cluster_a,
-            params: dict,
-            extra: dict,
-            cache: Optional[dict] = None
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        cluster_a,
+        params: dict,
+        extra: dict,
+        cache: Optional[dict] = None,
     ) -> bool:
-        """
-        Permits external factors to influence cluster creation.
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - cluster_a: a-side cluster label
-        - params: dict containing parameters for the algorithm
-        - extra: additional parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            true if match is permitted
+        """Permit external factors to influence cluster creation.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        cluster_a : int
+            A-side cluster label.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        extra : dict
+            Additional parameters for the algorithm.
+        cache : dict, optional
+            Dictionary containing values cached from previous calculations.
+
+        Returns
+        -------
+        bool
+            True if the match is permitted, otherwise False.
 
         """
         k = extra["k"]
@@ -255,15 +355,19 @@ def match_reset_func(
         return False
 
     def step_fit(self, X: np.ndarray, k: int) -> int:
-        """
-        fit the model to a single sample
+        """Fit the model to a single sample.
 
-        Parameters:
-        - X: data set
-        - k: sample index
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        k : int
+            The sample index.
 
-        Returns:
-            cluster label of the input sample
+        Returns
+        -------
+        int
+            The cluster label of the input sample.
 
         """
         match_reset_func = lambda i, w, cluster, params, cache: self.match_reset_func(
@@ -273,23 +377,24 @@ def step_fit(self, X: np.ndarray, k: int) -> int:
         return c_a
 
     def fit(self, X: np.ndarray, max_iter=1):
-        """
-        Fit the model to the data
+        """Fit the model to the data.
 
-        Parameters:
-        - X: data set
-        - max_iter: number of iterations to fit the model on the same data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to fit the model on.
+        max_iter : int
+            The number of iterations to fit the model on the same dataset.
 
         """
         # Check that X and y have correct shape
         self.X = X
 
         n = X.shape[0]
-        X_a = self.module_b.prepare_data(X)
+        X_a = self.module_a.prepare_data(X)
         X_b = self.module_b.prepare_data(X.T)
         self.validate_data(X_a, X_b)
 
-
         self.module_b = self.module_b.fit(X_b, max_iter=max_iter)
 
         # init module A
@@ -298,10 +403,10 @@ def fit(self, X: np.ndarray, max_iter=1):
 
         for _ in range(max_iter):
             for k in range(n):
-                self.module_a.pre_step_fit(X)
+                self.module_a.pre_step_fit(X_a)
                 c_a = self.step_fit(X_a, k)
                 self.module_a.labels_[k] = c_a
-                self.module_a.post_step_fit(X)
+                self.module_a.post_step_fit(X_a)
 
         self.rows_ = np.vstack(
             [
@@ -319,23 +424,19 @@ def fit(self, X: np.ndarray, max_iter=1):
         )
         return self
 
+    def visualize(self, cmap: Optional[Colormap] = None):
+        """Visualize the clustering of the data.
 
-    def visualize(
-            self,
-            cmap: Optional[Colormap] = None
-    ):
-        """
-        Visualize the clustering of the data
-
-        Parameters:
-        - cmap: some colormap
+        Parameters
+        ----------
+        cmap : matplotlib.colors.Colormap or str
+            The colormap to use for visualization.
 
         """
         import matplotlib.pyplot as plt
 
         if cmap is None:
-            from matplotlib.pyplot import cm
-            cmap=plt.cm.Blues
+            cmap = plt.cm.Blues
 
         plt.matshow(
             np.outer(np.sort(self.row_labels_) + 1, np.sort(self.column_labels_) + 1),
diff --git a/artlib/biclustering/__init__.py b/artlib/biclustering/__init__.py
index e69de29..36face3 100644
--- a/artlib/biclustering/__init__.py
+++ b/artlib/biclustering/__init__.py
@@ -0,0 +1,12 @@
+"""Biclustering is a data mining technique used to find subgroups of rows and columns in
+a matrix that exhibit similar patterns. Unlike traditional clustering, which only groups
+rows or columns independently, biclustering simultaneously clusters both dimensions,
+allowing for the discovery of local patterns in the data. It is commonly used in fields
+such as bioinformatics, particularly for gene expression data analysis, where subsets of
+genes and conditions may exhibit correlated behavior.
+
+The ART module contained herein allows for ART to solve biclustering problems.
+
+`Biclustering <https://en.wikipedia.org/wiki/Biclustering>`_
+
+"""
diff --git a/artlib/common/BaseART.py b/artlib/common/BaseART.py
index 3e11ce4..6e6abce 100644
--- a/artlib/common/BaseART.py
+++ b/artlib/common/BaseART.py
@@ -1,28 +1,31 @@
+"""Base class for all ART objects."""
 import numpy as np
-from typing import Optional, Callable, Iterable, Literal, List, Tuple
+from typing import Optional, Callable, Literal, List, Tuple, Union, Dict
 from copy import deepcopy
 from collections import defaultdict
 from matplotlib.axes import Axes
 from warnings import warn
 from sklearn.base import BaseEstimator, ClusterMixin
 from sklearn.utils.validation import check_is_fitted
-from artlib.common.utils import normalize, de_normalize
+from artlib.common.utils import normalize, de_normalize, IndexableOrKeyable
 import operator
 
 
 class BaseART(BaseEstimator, ClusterMixin):
-    # Generic implementation of Adaptive Resonance Theory (ART)
-    def __init__(self, params: dict):
-        """
+    """Generic implementation of Adaptive Resonance Theory (ART)"""
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+    def __init__(self, params: Dict):
+        """
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         self.validate_params(params)
         self.params = params
         self.sample_counter_ = 0
-        self.weight_sample_counter_: list[int] = []
+        self.weight_sample_counter_: List[int] = []
         self.d_min_ = None
         self.d_max_ = None
 
@@ -31,24 +34,29 @@ def __getattr__(self, key):
             return self.params[key]
         else:
             # If the key is not in params, raise an AttributeError
-            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'")
+            raise AttributeError(
+                f"'{type(self).__name__}' object has no attribute '{key}'"
+            )
 
     def __setattr__(self, key, value):
-        if key in self.__dict__.get('params', {}):
+        if key in self.__dict__.get("params", {}):
             # If key is in params, set its value
             self.params[key] = value
         else:
             # Otherwise, proceed with normal attribute setting
             super().__setattr__(key, value)
 
-
-    def get_params(self, deep: bool = True) -> dict:
+    def get_params(self, deep: bool = True) -> Dict:
         """
+        Parameters
+        ----------
+        deep : bool, default=True
+            If True, will return the parameters for this class and contained subobjects
+            that are estimators.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
-
-        Returns:
+        Returns
+        -------
+        dict
             Parameter names mapped to their values.
 
         """
@@ -57,19 +65,24 @@ def get_params(self, deep: bool = True) -> dict:
     def set_params(self, **params):
         """Set the parameters of this estimator.
 
-        Specific redefinition of sklearn.BaseEstimator.set_params for ART classes
+        Specific redefinition of `sklearn.BaseEstimator.set_params` for ART classes.
 
-        Parameters:
-        - **params : Estimator parameters.
+        Parameters
+        ----------
+        **params : dict
+            Estimator parameters.
+
+        Returns
+        -------
+        self : object
+            Estimator instance.
 
-        Returns:
-        - self : estimator instance
         """
         if not params:
             # Simple optimization to gain speed (inspect is slow)
             return self
         valid_params = self.get_params(deep=True)
-        local_params = dict()
+        local_params = dict(valid_params)
 
         nested_params = defaultdict(dict)  # grouped by prefix
         for key, value in params.items():
@@ -93,39 +106,48 @@ def set_params(self, **params):
         self.validate_params(local_params)
         return self
 
-
     def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+        """Prepare data for clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Normalized data.
 
-        Returns:
-            normalized data
         """
         normalized, self.d_max_, self.d_min_ = normalize(X, self.d_max_, self.d_min_)
         return normalized
 
     def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+        """Restore data to state prior to preparation.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-        Returns:
-            restored data
         """
         return de_normalize(X, d_max=self.d_max_, d_min=self.d_min_)
 
     @property
     def n_clusters(self) -> int:
-        """
-        get the current number of clusters
+        """Get the current number of clusters.
+
+        Returns
+        -------
+        int
+            The number of clusters.
 
-        Returns:
-            the number of clusters
         """
         if hasattr(self, "W"):
             return len(self.W)
@@ -133,22 +155,24 @@ def n_clusters(self) -> int:
             return 0
 
     @staticmethod
-    def validate_params(params: dict):
-        """
-        validate clustering parameters
+    def validate_params(params: Dict):
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         raise NotImplementedError
 
     def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
+        """Check the data has the correct dimensions.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         if not hasattr(self, "dim_"):
@@ -157,8 +181,7 @@ def check_dimensions(self, X: np.ndarray):
             assert X.shape[1] == self.dim_
 
     def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validates the data prior to clustering.
 
         Parameters:
         - X: data set
@@ -168,49 +191,81 @@ def validate_data(self, X: np.ndarray):
         assert np.all(X <= 1.0), "Data has not been normalized"
         self.check_dimensions(X)
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: Dict
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the activation of the cluster.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            cluster activation, cache used for later processing
+        Returns
+        -------
+        tuple
+            Cluster activation and cache used for later processing.
 
         """
         raise NotImplementedError
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        tuple
+            Cluster match criterion and cache used for later processing.
 
         """
         raise NotImplementedError
 
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, op: Callable = operator.ge) -> tuple[bool, dict]:
-        """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion binary, cache used for later processing
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+        op: Callable = operator.ge,
+    ) -> Tuple[bool, Dict]:
+        """Get the binary match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        tuple
+            Binary match criterion and cache used for later processing.
 
         """
         M, cache = self.match_criterion(i, w, params=params, cache=cache)
@@ -221,64 +276,109 @@ def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache:
         cache["match_criterion_bin"] = M_bin
         return M_bin, cache
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         raise NotImplementedError
 
-    def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+    def new_weight(self, i: np.ndarray, params: Dict) -> np.ndarray:
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         raise NotImplementedError
 
     def add_weight(self, new_w: np.ndarray):
-        """
-        add a new cluster weight
+        """Add a new cluster weight.
 
-        Parameters:
-        - new_w: new cluster weight to add
+        Parameters
+        ----------
+        new_w : np.ndarray
+            New cluster weight to add.
 
         """
         self.weight_sample_counter_.append(1)
         self.W.append(new_w)
 
     def set_weight(self, idx: int, new_w: np.ndarray):
-        """
-        set the value of a cluster weight
+        """Set the value of a cluster weight.
 
-        Parameters:
-        - idx: index of cluster to update
-        - new_w: new cluster weight
+        Parameters
+        ----------
+        idx : int
+            Index of cluster to update.
+        new_w : np.ndarray
+            New cluster weight.
 
         """
         self.weight_sample_counter_[idx] += 1
         self.W[idx] = new_w
 
-    def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
+    def _match_tracking(
+        self,
+        cache: Union[List[Dict], Dict],
+        epsilon: float,
+        params: Union[List[Dict], Dict],
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Perform match tracking using the specified method.
+
+        Parameters
+        ----------
+        cache : dict
+            Cached match criterion value.
+        epsilon : float
+            Small adjustment factor for match tracking.
+        params : dict
+            Parameters
+        method : Literal["MT+", "MT-", "MT0", "MT1", "MT~"]
+            Match tracking method to apply.
+
+        Returns
+        -------
+        bool
+            Whether to continue searching for a match.
+
+        """
+        assert isinstance(cache, dict)
+        assert isinstance(params, dict)
         M = cache["match_criterion"]
         if method == "MT+":
-            self.params["rho"] = M+epsilon
+            self.params["rho"] = M + epsilon
             return True
         elif method == "MT-":
             self.params["rho"] = M - epsilon
@@ -295,8 +395,10 @@ def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Lit
             raise ValueError(f"Invalid Match Tracking Method: {method}")
 
     @staticmethod
-    def _match_tracking_operator(method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> Callable:
-        if method in ["MT+", "MT-","MT1"]:
+    def _match_tracking_operator(
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]
+    ) -> Callable:
+        if method in ["MT+", "MT-", "MT1"]:
             return operator.ge
         elif method in ["MT0", "MT~"]:
             return operator.gt
@@ -306,61 +408,69 @@ def _match_tracking_operator(method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"])
     def _set_params(self, new_params):
         self.params = new_params
 
-    def _deep_copy_params(self) -> dict:
+    def _deep_copy_params(self) -> Dict:
         return deepcopy(self.params)
 
-
-    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0) -> int:
-        """
-        fit the model to a single sample
-
-        Parameters:
-        - x: data sample
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
-
-
-        Returns:
-            cluster label of the input sample
+    def step_fit(
+        self,
+        x: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ) -> int:
+        """Fit the model to a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        match_reset_func : callable, optional
+            A callable that influences cluster creation.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, default="MT+"
+            Method for resetting match criterion.
+        epsilon : float, default=0.0
+            Epsilon value used for adjusting match criterion.
+
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
 
         """
         self.sample_counter_ += 1
         base_params = self._deep_copy_params()
-        mt_operator = self._match_tracking_operator(match_reset_method)
+        mt_operator = self._match_tracking_operator(match_tracking)
         if len(self.W) == 0:
             w_new = self.new_weight(x, self.params)
             self.add_weight(w_new)
             return 0
         else:
-
-            if match_reset_method in ["MT~"] and match_reset_func is not None:
-                T_values, T_cache = zip(*[
-                    self.category_choice(x, w, params=self.params)
-                    if match_reset_func(x, w, c_, params=self.params, cache=None)
-                    else (np.nan, None)
-                    for c_, w in enumerate(self.W)
-                ])
+            if match_tracking in ["MT~"] and match_reset_func is not None:
+                T_values, T_cache = zip(
+                    *[
+                        self.category_choice(x, w, params=self.params)
+                        if match_reset_func(x, w, c_, params=self.params, cache=None)
+                        else (np.nan, None)
+                        for c_, w in enumerate(self.W)
+                    ]
+                )
             else:
-                T_values, T_cache = zip(*[self.category_choice(x, w, params=self.params) for w in self.W])
+                T_values, T_cache = zip(
+                    *[self.category_choice(x, w, params=self.params) for w in self.W]
+                )
             T = np.array(T_values)
             while any(~np.isnan(T)):
                 c_ = int(np.nanargmax(T))
                 w = self.W[c_]
                 cache = T_cache[c_]
-                m, cache = self.match_criterion_bin(x, w, params=self.params, cache=cache, op=mt_operator)
-                if match_reset_method in ["MT~"] and match_reset_func is not None:
+                m, cache = self.match_criterion_bin(
+                    x, w, params=self.params, cache=cache, op=mt_operator
+                )
+                if match_tracking in ["MT~"] and match_reset_func is not None:
                     no_match_reset = True
                 else:
-                    no_match_reset = (
-                            match_reset_func is None or
-                            match_reset_func(x, w, c_, params=self.params, cache=cache)
+                    no_match_reset = match_reset_func is None or match_reset_func(
+                        x, w, c_, params=self.params, cache=cache
                     )
                 if m and no_match_reset:
                     self.set_weight(c_, self.update(x, w, self.params, cache=cache))
@@ -369,7 +479,9 @@ def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, m
                 else:
                     T[c_] = np.nan
                     if m and not no_match_reset:
-                        keep_searching = self._match_tracking(cache, epsilon, self.params, match_reset_method)
+                        keep_searching = self._match_tracking(
+                            cache, epsilon, self.params, match_tracking
+                        )
                         if not keep_searching:
                             T[:] = np.nan
 
@@ -380,14 +492,17 @@ def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, m
             return c_new
 
     def step_pred(self, x) -> int:
-        """
-        predict the label for a single sample
+        """Predict the label for a single sample.
 
-        Parameters:
-        - x: data sample
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
 
-        Returns:
-            cluster label of the input sample
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
 
         """
         assert len(self.W) >= 0, "ART module is not fit."
@@ -397,126 +512,250 @@ def step_pred(self, x) -> int:
         return c_
 
     def pre_step_fit(self, X: np.ndarray):
-        """
-        undefined function called prior to each sample fit. Useful for cluster pruning
+        """Undefined function called prior to each sample fit. Useful for cluster
+        pruning.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         # this is where pruning steps can go
         pass
 
     def post_step_fit(self, X: np.ndarray):
-        """
-        undefined function called after each sample fit. Useful for cluster pruning
+        """Undefined function called after each sample fit. Useful for cluster pruning.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         # this is where pruning steps can go
         pass
 
     def post_fit(self, X: np.ndarray):
-        """
-        undefined function called after fit. Useful for cluster pruning
+        """Undefined function called after fit. Useful for cluster pruning.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         # this is where pruning steps can go
         pass
 
-
-    def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: Optional[Callable] = None, max_iter=1, match_reset_method:Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0, verbose: bool = False):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set
-        - y: not used. For compatibility.
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        match_reset_func: Optional[Callable] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+        verbose: bool = False,
+    ):
+        """Fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        y : np.ndarray, optional
+            Not used. For compatibility.
+        match_reset_func : callable, optional
+            A callable that influences cluster creation.
+        max_iter : int, default=1
+            Number of iterations to fit the model on the same dataset.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, default="MT+"
+            Method for resetting match criterion.
+        epsilon : float, default=0.0
+            Epsilon value used for adjusting match criterion.
+        verbose : bool, default=False
+            If True, displays progress of the fitting process.
 
         """
         self.validate_data(X)
         self.check_dimensions(X)
         self.is_fitted_ = True
 
-        self.W: list[np.ndarray] = []
-        self.labels_ = np.zeros((X.shape[0], ), dtype=int)
+        self.W: List[np.ndarray] = []
+        self.labels_ = np.zeros((X.shape[0],), dtype=int)
         for _ in range(max_iter):
             if verbose:
                 from tqdm import tqdm
+
                 x_iter = tqdm(enumerate(X), total=int(X.shape[0]))
             else:
                 x_iter = enumerate(X)
             for i, x in x_iter:
                 self.pre_step_fit(X)
-                c = self.step_fit(x, match_reset_func=match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
+                c = self.step_fit(
+                    x,
+                    match_reset_func=match_reset_func,
+                    match_tracking=match_tracking,
+                    epsilon=epsilon,
+                )
                 self.labels_[i] = c
                 self.post_step_fit(X)
         self.post_fit(X)
         return self
 
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Iteratively fit the model to the data.
 
-    def partial_fit(self, X: np.ndarray, match_reset_func: Optional[Callable] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        iteratively fit the model to the data
-
-        Parameters:
-        - X: data set
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        match_reset_func : callable, optional
+            A callable that influences cluster creation.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, default="MT+"
+            Method for resetting match criterion.
+        epsilon : float, default=0.0
+            Epsilon value used for adjusting match criterion.
 
         """
-
         self.validate_data(X)
         self.check_dimensions(X)
-        self.is_fitted_ =  True
+        self.is_fitted_ = True
 
-        if not hasattr(self, 'W'):
-            self.W: list[np.ndarray] = []
-            self.labels_ = np.zeros((X.shape[0], ), dtype=int)
+        if not hasattr(self, "W"):
+            self.W = []
+            self.labels_ = np.zeros((X.shape[0],), dtype=int)
             j = 0
         else:
             j = len(self.labels_)
-            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode='constant')
+            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode="constant")
         for i, x in enumerate(X):
-            c = self.step_fit(x, match_reset_func=match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
-            self.labels_[i+j] = c
+            c = self.step_fit(
+                x,
+                match_reset_func=match_reset_func,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
+            self.labels_[i + j] = c
         return self
 
+    def fit_gif(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        match_reset_func: Optional[Callable] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+        verbose: bool = False,
+        ax: Optional[Axes] = None,
+        filename: Optional[str] = None,
+        colors: Optional[IndexableOrKeyable] = None,
+        n_cluster_estimate: int = 20,
+        fps: int = 5,
+        **kwargs,
+    ):
+        """Fit the model to the data and make a gif of the process.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        y : np.ndarray, optional
+            Not used. For compatibility.
+        match_reset_func : callable, optional
+            A callable that influences cluster creation.
+        max_iter : int, default=1
+            Number of iterations to fit the model on the same dataset.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, default="MT+"
+            Method for resetting match criterion.
+        epsilon : float, default=0.0
+            Epsilon value used for adjusting match criterion.
+        verbose : bool, default=False
+            If True, displays progress of the fitting process.
+        ax : matplotlib.axes.Axes, optional
+            Figure axes.
+        colors : IndexableOrKeyable, optional
+            Colors to use for each cluster.
+        n_cluster_estimate : int, default=20
+            estimate of number of clusters. Used for coloring plot.
+        fps : int, default=5
+            gif frames per second
+        **kwargs : dict
+            see :func: `artlib.common.BaseART.visualize`
 
-    def predict(self, X: np.ndarray) -> np.ndarray:
         """
-        predict labels for the data
+        import matplotlib.pyplot as plt
+        from matplotlib.animation import PillowWriter
 
-        Parameters:
-        - X: data set
+        if ax is None:
+            fig, ax = plt.subplots()
+            ax.set_xlim(-0.1, 1.1)
+            ax.set_ylim(-0.1, 1.1)
+        if filename is None:
+            filename = f"fit_gif_{self.__class__.__name__}.gif"
+        if colors is None:
+            from matplotlib.pyplot import cm
 
-        Returns:
-            labels for the data
+            colors = cm.rainbow(np.linspace(0, 1, n_cluster_estimate))
+            black = np.array([[0, 0, 0, 1]])  # RGBA for black
+            colors = np.vstack((colors, black))  # Add black at the end
 
-        """
+        self.validate_data(X)
+        self.check_dimensions(X)
+        self.is_fitted_ = True
+
+        self.W = []
+        self.labels_ = -np.ones((X.shape[0],), dtype=int)
+
+        writer = PillowWriter(fps=fps)
+        with writer.saving(fig, filename, dpi=80):
+            for _ in range(max_iter):
+                if verbose:
+                    from tqdm import tqdm
 
+                    x_iter = tqdm(enumerate(X), total=int(X.shape[0]))
+                else:
+                    x_iter = enumerate(X)
+                for i, x in x_iter:
+                    self.pre_step_fit(X)
+                    c = self.step_fit(
+                        x,
+                        match_reset_func=match_reset_func,
+                        match_tracking=match_tracking,
+                        epsilon=epsilon,
+                    )
+                    self.labels_[i] = c
+                    self.post_step_fit(X)
+                    ax.clear()
+                    ax.set_xlim(-0.1, 1.1)
+                    ax.set_ylim(-0.1, 1.1)
+                    self.visualize(X, self.labels_, ax, colors=colors, **kwargs)
+                    writer.grab_frame()
+            self.post_fit(X)
+            return self
+
+    def predict(self, X: np.ndarray) -> np.ndarray:
+        """Predict labels for the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Labels for the data.
+
+        """
         check_is_fitted(self)
         self.validate_data(X)
         self.check_dimensions(X)
@@ -528,48 +767,75 @@ def predict(self, X: np.ndarray) -> np.ndarray:
         return y
 
     def shrink_clusters(self, shrink_ratio: float = 0.1):
-        return self
+        """Shrink the clusters by a specified ratio.
+
+        Parameters
+        ----------
+        shrink_ratio : float, optional
+            The ratio by which to shrink the clusters. Must be between 0 and 1.
+            Default is 0.1.
+
+        Returns
+        -------
+        self : object
+            Returns the instance with shrunken clusters.
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
         """
-        undefined function for visualizing the bounds of each cluster
+        return self
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Undefined function for visualizing the bounds of each cluster.
+
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, default=1
+            Width of boundary line.
 
         """
         raise NotImplementedError
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        undefined function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
+        """Undefined function for getting centers of each cluster. Used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
         raise NotImplementedError
 
-
     def visualize(
-            self,
-            X: np.ndarray,
-            y: np.ndarray,
-            ax: Optional[Axes] = None,
-            marker_size: int = 10,
-            linewidth: int = 1,
-            colors: Optional[Iterable] = None
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        ax: Optional[Axes] = None,
+        marker_size: int = 10,
+        linewidth: int = 1,
+        colors: Optional[IndexableOrKeyable] = None,
     ):
-        """
-        Visualize the clustering of the data
-
-        Parameters:
-        - X: data set
-        - y: sample labels
-        - ax: figure axes
-        - marker_size: size used for data points
-        - linewidth: width of boundary line
-        - colors: colors to use for each cluster
+        """Visualize the clustering of the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        y : np.ndarray
+            Sample labels.
+        ax : matplotlib.axes.Axes, optional
+            Figure axes.
+        marker_size : int, default=10
+            Size used for data points.
+        linewidth : int, default=1
+            Width of boundary line.
+        colors : IndexableOrKeyable, optional
+            Colors to use for each cluster.
 
         """
         import matplotlib.pyplot as plt
@@ -579,20 +845,20 @@ def visualize(
 
         if colors is None:
             from matplotlib.pyplot import cm
+
             colors = cm.rainbow(np.linspace(0, 1, self.n_clusters))
 
         for k, col in enumerate(colors):
             cluster_data = y == k
-            plt.scatter(X[cluster_data, 0], X[cluster_data, 1], color=col, marker=".", s=marker_size)
+            plt.scatter(
+                X[cluster_data, 0],
+                X[cluster_data, 1],
+                color=col,
+                marker=".",
+                s=marker_size,
+            )
 
         try:
             self.plot_cluster_bounds(ax, colors, linewidth)
         except NotImplementedError:
             warn(f"{self.__class__.__name__} does not support plotting cluster bounds.")
-
-
-
-
-
-
-
diff --git a/artlib/common/BaseARTMAP.py b/artlib/common/BaseARTMAP.py
index d54f4b1..fa5da4c 100644
--- a/artlib/common/BaseARTMAP.py
+++ b/artlib/common/BaseARTMAP.py
@@ -1,31 +1,40 @@
+"""Base class for all ARTMAP objects."""
 import numpy as np
-from typing import Union, Optional, Iterable, Literal
+from typing import Union, Optional, Literal
 from collections import defaultdict
 from matplotlib.axes import Axes
 from sklearn.base import BaseEstimator, ClassifierMixin, ClusterMixin
+from artlib.common.utils import IndexableOrKeyable
+
 
 class BaseARTMAP(BaseEstimator, ClassifierMixin, ClusterMixin):
+    """Generic implementation of Adaptive Resonance Theory MAP (ARTMAP)"""
 
     def __init__(self):
+        """Instantiate the BaseARTMAP object."""
         self.map: dict[int, int] = dict()
 
     def set_params(self, **params):
         """Set the parameters of this estimator.
 
-        Specific redefinition of sklearn.BaseEstimator.set_params for ARTMAP classes
+        Specific redefinition of `sklearn.BaseEstimator.set_params` for ARTMAP classes.
 
-        Parameters:
-        - **params : Estimator parameters.
+        Parameters
+        ----------
+        **params : dict
+            Estimator parameters.
 
-        Returns:
-        - self : estimator instance
-        """
+        Returns
+        -------
+        self : object
+            Estimator instance.
 
+        """
         if not params:
             # Simple optimization to gain speed (inspect is slow)
             return self
         valid_params = self.get_params(deep=True)
-        local_params = dict()
+        local_params = dict(valid_params)
 
         nested_params = defaultdict(dict)  # grouped by prefix
         for key, value in params.items():
@@ -49,14 +58,17 @@ def set_params(self, **params):
         return self
 
     def map_a2b(self, y_a: Union[np.ndarray, int]) -> Union[np.ndarray, int]:
-        """
-        map an a-side label to a b-side label
+        """Map an a-side label to a b-side label.
 
-        Parameters:
-        - y_a: side a label(s)
+        Parameters
+        ----------
+        y_a : Union[np.ndarray, int]
+            Side A label(s).
 
-        Returns:
-            side B cluster label(s)
+        Returns
+        -------
+        Union[np.ndarray, int]
+            Side B cluster label(s).
 
         """
         if isinstance(y_a, int):
@@ -65,96 +77,141 @@ def map_a2b(self, y_a: Union[np.ndarray, int]) -> Union[np.ndarray, int]:
         return np.array([self.map[x] for x in u], dtype=int)[inv].reshape(y_a.shape)
 
     def validate_data(self, X: np.ndarray, y: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the data prior to clustering.
 
-        Parameters:
-        - X: data set A
-        - y: data set B
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset A.
+        y : np.ndarray
+            Dataset B.
 
         """
         raise NotImplementedError
 
-    def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: data set B
-        - max_iter: number of iterations to fit the model on the same data set
+    def fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+    ):
+        """Fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset A.
+        y : np.ndarray
+            Dataset B.
+        max_iter : int, optional
+            Number of iterations to fit the model on the same dataset.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion.
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 1e-10.
 
         """
         raise NotImplementedError
 
-    def partial_fit(self, X: np.ndarray, y: np.ndarray, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10):
-        """
-        Partial fit the model to the data
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+    ):
+        """Partial fit the model to the data.
 
-        Parameters:
-        - X: data set A
-        - y: data set B
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset A.
+        y : np.ndarray
+            Dataset B.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion.
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 1e-10.
 
         """
         raise NotImplementedError
 
     def predict(self, X: np.ndarray) -> np.ndarray:
-        """
-        predict labels for the data
+        """Predict labels for the data.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset A.
 
-        Returns:
-            B labels for the data
+        Returns
+        -------
+        np.ndarray
+            B-side labels for the data.
 
         """
         raise NotImplementedError
 
     def predict_ab(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
-        """
-        predict labels for the data, both A-side and B-side
+        """Predict labels for the data, both A-side and B-side.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset A.
 
-        Returns:
-            A labels for the data, B labels for the data
+        Returns
+        -------
+        tuple[np.ndarray, np.ndarray]
+            A-side labels for the data, B-side labels for the data.
 
         """
         raise NotImplementedError
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         raise NotImplementedError
 
     def visualize(
-            self,
-            X: np.ndarray,
-            y: np.ndarray,
-            ax: Optional[Axes] = None,
-            marker_size: int = 10,
-            linewidth: int = 1,
-            colors: Optional[Iterable] = None
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        ax: Optional[Axes] = None,
+        marker_size: int = 10,
+        linewidth: int = 1,
+        colors: Optional[IndexableOrKeyable] = None,
     ):
-        """
-        Visualize the clustering of the data
-
-        Parameters:
-        - X: data set
-        - y: sample labels
-        - ax: figure axes
-        - marker_size: size used for data points
-        - linewidth: width of boundary line
-        - colors: colors to use for each cluster
+        """Visualize the clustering of the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset.
+        y : np.ndarray
+            Sample labels.
+        ax : matplotlib.axes.Axes, optional
+            Figure axes, by default None.
+        marker_size : int, optional
+            Size used for data points, by default 10.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
+        colors : IndexableOrKeyable, optional
+            Colors to use for each cluster, by default None.
 
         """
-        raise NotImplementedError
\ No newline at end of file
+        raise NotImplementedError
diff --git a/artlib/common/VAT.py b/artlib/common/VAT.py
index 37fc7ae..17d8b79 100644
--- a/artlib/common/VAT.py
+++ b/artlib/common/VAT.py
@@ -1,24 +1,50 @@
-# Bezdek, J. C., & Hathaway, R. J. (2002). VAT: A tool for visual assessment of cluster tendency.
-# Proceedings of the 2002 International Joint Conference on Neural Networks. doi:10.1109/IJCNN.2002.1007487
+"""VAT.
+
+.. # Bezdek, J. C., & Hathaway, R. J. (2002). .. # VAT: A tool for visual assessment of
+cluster tendency. .. # Proceedings of the 2002 International Joint Conference on Neural
+Networks. .. # doi:10.1109/IJCNN.2002.1007487
+
+"""
 import numpy as np
 from typing import Optional, Tuple, Callable
 from scipy.spatial.distance import pdist, squareform
 
 
-def VAT(data: np.ndarray, distance_metric: Optional[Callable] = lambda X: pdist(X, "euclidean")) -> Tuple[np.ndarray, np.ndarray]:
-    """
-    Visual Assessment of cluster Tendency (VAT) algorithm.
+def VAT(
+    data: np.ndarray,
+    distance_metric: Optional[Callable] = lambda X: pdist(X, "euclidean"),
+) -> Tuple[np.ndarray, np.ndarray]:
+    """Visual Assessment of Cluster Tendency (VAT) algorithm.
+
+    VAT was originally designed as a visualization tool for clustering behavior of data.
+    When the VAT-reordered distance matrix is plotted as an image, clusters will appear
+    in visually distinct groups along the diagonal. However, it has since been
+    discovered that the reordering significantly improves the results of order-dependent
+    clustering methods like ART. It is therefore recommended to pre-process data with
+    VAT prior to presentation when possible.
+
+    .. # Bezdek, J. C., & Hathaway, R. J. (2002).
+    .. # VAT: A tool for visual assessment of cluster tendency.
+    .. # Proceedings of the 2002 International Joint Conference on Neural Networks.
+    .. # doi:10.1109/IJCNN.2002.1007487
 
-    Parameters:
-    - data: Input data set as a 2D numpy array where each row is a sample.
-    - distance_metric: Optional callable function to calculate pairwise distances.
-      Defaults to Euclidean distance using pdist.
-      If None, assume data is a pre-computed distance matrix
+    .. bibliography:: ../../references.bib
+       :filter: citation_key == "bezdek2002vat"
 
-    Returns:
-        Tuple containing:
+    Parameters
+    ----------
+    data : np.ndarray
+        Input dataset as a 2D numpy array where each row is a sample.
+    distance_metric : callable, optional
+        Callable function to calculate pairwise distances. Defaults to Euclidean
+        distance using `pdist`. If None, assumes data is a pre-computed distance matrix.
+
+    Returns
+    -------
+    Tuple[np.ndarray, np.ndarray]
         - Reordered distance matrix reflecting cluster structure.
         - Reordered list of indices indicating the optimal clustering order.
+
     """
     if distance_metric is None:
         pairwise_dist = data
@@ -40,5 +66,3 @@ def VAT(data: np.ndarray, distance_metric: Optional[Callable] = lambda X: pdist(
         remaining.pop(jx)
 
     return pairwise_dist[np.ix_(indicies, indicies)], np.array(indicies)
-
-
diff --git a/artlib/common/__init__.py b/artlib/common/__init__.py
index e69de29..ef31922 100644
--- a/artlib/common/__init__.py
+++ b/artlib/common/__init__.py
@@ -0,0 +1,2 @@
+"""This module implements several functions and classes used across ARTLib as well as
+some functions like VAT which are useful in a variety of contexts."""
diff --git a/artlib/common/utils.py b/artlib/common/utils.py
index 285e804..98a6a3c 100644
--- a/artlib/common/utils.py
+++ b/artlib/common/utils.py
@@ -1,21 +1,35 @@
+"""General utilities used throughout ARTLib."""
 import numpy as np
-from typing import Tuple, Optional
+from typing import Tuple, Optional, Mapping, Sequence, Union, Any
+
+IndexableOrKeyable = Union[Mapping[Any, Any], Sequence[Any]]
+
+
+def normalize(
+    data: np.ndarray,
+    d_max: Optional[np.ndarray] = None,
+    d_min: Optional[np.ndarray] = None,
+) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """Normalize data column-wise between 0 and 1.
+
+    Parameters
+    ----------
+    data : np.ndarray
+        2D array of dataset (rows = samples, columns = features).
+    d_max : np.ndarray, optional
+        Maximum values for each column.
+    d_min : np.ndarray, optional
+        Minimum values for each column.
+
+    Returns
+    -------
+    np.ndarray
+        Normalized data.
+    np.ndarray
+        Maximum values for each column.
+    np.ndarray
+        Minimum values for each column.
 
-
-def normalize(data: np.ndarray, d_max: Optional[np.ndarray] = None, d_min: Optional[np.ndarray] = None) -> Tuple[
-    np.ndarray, np.ndarray, np.ndarray]:
-    """
-    Normalize data column-wise between 0 and 1.
-
-    Parameters:
-    - data: 2D array of data set (rows = samples, columns = features)
-    - d_max: Optional, maximum values for each column
-    - d_min: Optional, minimum values for each column
-
-    Returns:
-    - normalized: normalized data
-    - d_max: maximum values for each column
-    - d_min: minimum values for each column
     """
     if d_min is None:
         d_min = np.min(data, axis=0)
@@ -27,41 +41,57 @@ def normalize(data: np.ndarray, d_max: Optional[np.ndarray] = None, d_min: Optio
 
 
 def de_normalize(data: np.ndarray, d_max: np.ndarray, d_min: np.ndarray) -> np.ndarray:
-    """
-    Restore column-wise normalized data to original scale.
-
-    Parameters:
-    - data: normalized data
-    - d_max: maximum values for each column
-    - d_min: minimum values for each column
+    """Restore column-wise normalized data to original scale.
+
+    Parameters
+    ----------
+    data : np.ndarray
+        Normalized data.
+    d_max : np.ndarray
+        Maximum values for each column.
+    d_min : np.ndarray
+        Minimum values for each column.
+
+    Returns
+    -------
+    np.ndarray
+        De-normalized data.
 
-    Returns:
-    - De-normalized data
     """
     return data * (d_max - d_min) + d_min
 
+
 def compliment_code(data: np.ndarray) -> np.ndarray:
-    """
-    compliment code data
+    """Compliment code the data.
+
+    Parameters
+    ----------
+    data : np.ndarray
+        Dataset.
 
-    Parameters:
-    - data: data set
+    Returns
+    -------
+    np.ndarray
+        Compliment coded data.
 
-    Returns:
-        compliment coded data
     """
-    cc_data = np.hstack([data, 1.0-data])
+    cc_data = np.hstack([data, 1.0 - data])
     return cc_data
 
+
 def de_compliment_code(data: np.ndarray) -> np.ndarray:
-    """
-    finds centroid of compliment coded data
+    """Find the centroid of compliment coded data.
 
-    Parameters:
-    - data: data set
+    Parameters
+    ----------
+    data : np.ndarray
+        Dataset.
+
+    Returns
+    -------
+    np.ndarray
+        De-compliment coded data.
 
-    Returns:
-        compliment coded data
     """
     # Get the shape of the array
     n, total_columns = data.shape
@@ -74,47 +104,62 @@ def de_compliment_code(data: np.ndarray) -> np.ndarray:
 
     # Split the array into two arrays of shape n x m
     arr1 = data[:, :m]
-    arr2 = 1-data[:, m:]
+    arr2 = 1 - data[:, m:]
 
     # Find the element-wise mean
     mean_array = (arr1 + arr2) / 2
 
     return mean_array
 
+
 def l1norm(x: np.ndarray) -> float:
-    """
-    get l1 norm of a vector
+    """Get the L1 norm of a vector.
 
-    Parameters:
-    - x: some vector
+    Parameters
+    ----------
+    x : np.ndarray
+        Input vector.
+
+    Returns
+    -------
+    float
+        L1 norm.
 
-    Returns:
-        l1 norm
     """
     return float(np.sum(np.absolute(x)))
 
+
 def l2norm2(data: np.ndarray) -> float:
-    """
-    get (l2 norm)^2 of a vector
+    """Get the squared L2 norm of a vector.
 
-    Parameters:
-    - x: some vector
+    Parameters
+    ----------
+    data : np.ndarray
+        Input vector.
 
-    Returns:
-        (l2 norm)^2
-    """
-    return float(np.matmul(data, data))
+    Returns
+    -------
+    float
+        Squared L2 norm.
 
-def fuzzy_and(x: np.ndarray, y: np.ndarray) -> np.ndarray:
     """
-    get the fuzzy AND operation between two vectors
+    return float(np.matmul(data, data))
 
-    Parameters:
-    - a: some vector
-    - b: some vector
 
-    Returns:
-        Fuzzy AND result
+def fuzzy_and(x: np.ndarray, y: np.ndarray) -> np.ndarray:
+    """Get the fuzzy AND operation between two vectors.
+
+    Parameters
+    ----------
+    x : np.ndarray
+        First input vector.
+    y : np.ndarray
+        Second input vector.
+
+    Returns
+    -------
+    np.ndarray
+        Fuzzy AND result.
 
     """
     return np.minimum(x, y)
diff --git a/artlib/common/visualization.py b/artlib/common/visualization.py
index 7214a9d..0d499ce 100644
--- a/artlib/common/visualization.py
+++ b/artlib/common/visualization.py
@@ -1,26 +1,37 @@
+"""Collection of visualization utilities."""
 import numpy as np
 from matplotlib.axes import Axes
 
+
 def plot_gaussian_contours_fading(
-        ax: Axes,
-        mean: np.ndarray,
-        std_dev: np.ndarray,
-        color: np.ndarray,
-        max_std: int = 2,
-        sigma_steps: float = 0.25,
-        linewidth: int = 1
+    ax: Axes,
+    mean: np.ndarray,
+    std_dev: np.ndarray,
+    color: np.ndarray,
+    max_std: int = 2,
+    sigma_steps: float = 0.25,
+    linewidth: int = 1,
 ):
-    """
-    Plots concentric ellipses to represent the contours of a 2D Gaussian distribution, with fading colors.
-
-    Parameters:
-    - ax: Matplotlib axis object. If None, creates a new figure and axis.
-    - mean: A numpy array representing the mean (μ) of the distribution.
-    - std_dev: A numpy array representing the standard deviation (σ) of the distribution.
-    - color: A 4D numpy array including RGB and alpha channels to specify the color and initial opacity.
-    - max_std: Max standard deviations to draw contours to. Default is 2.
-    - sigma_steps: Step size in standard deviations for each contour. Default is 0.25.
-    - linewidth: width of boundary line
+    """Plot concentric ellipses to represent the contours of a 2D Gaussian distribution
+    with fading colors.
+
+    Parameters
+    ----------
+    ax : matplotlib.axes.Axes
+        Matplotlib axis object to plot the ellipses.
+    mean : np.ndarray
+        A numpy array representing the mean (μ) of the distribution.
+    std_dev : np.ndarray
+        A numpy array representing the standard deviation (σ) of the distribution.
+    color : np.ndarray
+        A 4D numpy array including RGB and alpha channels to specify the color and
+        initial opacity.
+    max_std : int, optional
+        Maximum number of standard deviations to draw contours to, by default 2.
+    sigma_steps : float, optional
+        Step size in standard deviations for each contour, by default 0.25.
+    linewidth : int, optional
+        Width of the boundary line, by default 1.
 
     """
     from matplotlib.patches import Ellipse
@@ -30,7 +41,7 @@ def plot_gaussian_contours_fading(
     alphas = np.linspace(1, 0.1, steps)
 
     if len(color) != 4:
-        color = np.concatenate([color, [1.]])
+        color = np.concatenate([color, [1.0]])
 
     for i, alpha in zip(range(1, steps + 1), alphas):
         # Adjust the alpha value of the color
@@ -38,44 +49,68 @@ def plot_gaussian_contours_fading(
         current_color[-1] = alpha  # Update the alpha channel
 
         # Width and height of the ellipse are 2*i*sigma_steps times the std_dev values
-        width, height = 2 * i * sigma_steps * std_dev[0], 2 * i * sigma_steps * std_dev[1]
-        ellipse = Ellipse(xy=(mean[0], mean[1]), width=width, height=height, edgecolor=current_color, facecolor='none', linewidth=linewidth,
-                          linestyle='dashed', label=f'{i * sigma_steps}σ')
+        width, height = (
+            2 * i * sigma_steps * std_dev[0],
+            2 * i * sigma_steps * std_dev[1],
+        )
+        ellipse = Ellipse(
+            xy=(mean[0], mean[1]),
+            width=width,
+            height=height,
+            edgecolor=current_color,
+            facecolor="none",
+            linewidth=linewidth,
+            linestyle="dashed",
+            label=f"{i * sigma_steps}σ",
+        )
         ax.add_patch(ellipse)
 
 
 def plot_gaussian_contours_covariance(
-        ax: Axes,
-        mean: np.ndarray,
-        covariance: np.ndarray,
-        color: np.ndarray,
-        max_std: int = 2,
-        sigma_steps: float = 0.25,
-        linewidth: int = 1
+    ax: Axes,
+    mean: np.ndarray,
+    covariance: np.ndarray,
+    color: np.ndarray,
+    max_std: int = 2,
+    sigma_steps: float = 0.25,
+    linewidth: int = 1,
 ):
-    """
-    Plots concentric ellipses to represent the contours of a 2D Gaussian distribution, with fading colors.
-    Accepts a covariance matrix to properly represent the distribution's orientation and shape.
-
-
-    Parameters:
-    - ax: Matplotlib axis object. If None, creates a new figure and axis.
-    - mean: A numpy array representing the mean (μ) of the distribution.
-    - covariance: A 2x2 numpy array representing the covariance matrix of the distribution.
-    - color: A 4D numpy array including RGB and alpha channels to specify the color and initial opacity.
-    - max_std: Max standard deviations to draw contours to. Default is 2.
-    - sigma_steps: Step size in standard deviations for each contour. Default is 0.25.
-    - linewidth: width of boundary line
+    """Plot concentric ellipses to represent the contours of a 2D Gaussian distribution
+    with fading colors. Accepts a covariance matrix to properly represent the
+    distribution's orientation and shape.
+
+    Parameters
+    ----------
+    ax : matplotlib.axes.Axes
+        Matplotlib axis object to plot the ellipses.
+    mean : np.ndarray
+        A numpy array representing the mean (μ) of the distribution.
+    covariance : np.ndarray
+        A 2x2 numpy array representing the covariance matrix of the distribution.
+    color : np.ndarray
+        A 4D numpy array including RGB and alpha channels to specify the color and
+        initial opacity.
+    max_std : int, optional
+        Maximum number of standard deviations to draw contours to, by default 2.
+    sigma_steps : float, optional
+        Step size in standard deviations for each contour, by default 0.25.
+    linewidth : int, optional
+        Width of the boundary line, by default 1.
 
     """
     from matplotlib.patches import Ellipse
 
     # Calculate the eigenvalues and eigenvectors of the covariance matrix
     eigenvalues, eigenvectors = np.linalg.eig(covariance)
-    major_axis = np.sqrt(eigenvalues[0])  # The major axis length (sqrt of larger eigenvalue)
-    minor_axis = np.sqrt(eigenvalues[1])  # The minor axis length (sqrt of smaller eigenvalue)
+    major_axis = np.sqrt(
+        eigenvalues[0]
+    )  # The major axis length (sqrt of larger eigenvalue)
+    minor_axis = np.sqrt(
+        eigenvalues[1]
+    )  # The minor axis length (sqrt of smaller eigenvalue)
     angle = np.arctan2(
-        *eigenvectors[:, 0][::-1])  # Angle in radians between the x-axis and the major axis of the ellipse
+        *eigenvectors[:, 0][::-1]
+    )  # Angle in radians between the x-axis and the major axis of the ellipse
 
     # Calculate the number of steps
     steps = int(max_std / sigma_steps)
@@ -87,31 +122,49 @@ def plot_gaussian_contours_covariance(
         current_color[-1] = alpha  # Update the alpha channel
 
         # Width and height of the ellipse based on the covariance
-        width, height = 2 * i * sigma_steps * major_axis * 2, 2 * i * sigma_steps * minor_axis * 2
-        ellipse = Ellipse(xy=(mean[0], mean[1]), width=width, height=height, angle=float(np.degrees(angle)),
-                          edgecolor=current_color, facecolor='None', linewidth=linewidth,
-                          linestyle='dashed', label=f'{i * sigma_steps}σ')
+        width, height = (
+            2 * i * sigma_steps * major_axis * 2,
+            2 * i * sigma_steps * minor_axis * 2,
+        )
+        ellipse = Ellipse(
+            xy=(mean[0], mean[1]),
+            width=width,
+            height=height,
+            angle=float(np.degrees(angle)),
+            edgecolor=current_color,
+            facecolor="None",
+            linewidth=linewidth,
+            linestyle="dashed",
+            label=f"{i * sigma_steps}σ",
+        )
         ax.add_patch(ellipse)
 
 
 def plot_weight_matrix_as_ellipse(
-        ax: Axes,
-        s: float,
-        W: np.ndarray,
-        mean: np.ndarray,
-        color: np.ndarray,
-        linewidth: int = 1
+    ax: Axes,
+    s: float,
+    W: np.ndarray,
+    mean: np.ndarray,
+    color: np.ndarray,
+    linewidth: int = 1,
 ):
-    """
-    Plots the transformation of a unit circle by the weight matrix W as an ellipse.
-
-    Parameters:
-    - ax: Matplotlib axis object. If None, creates a new figure and axis.
-    - mean: The center point (x, y) of the ellipse.
-    - s: Scalar to scale the weight matrix W.
-    - W: 2x2 weight matrix.
-    - color: Color of the ellipse.
-    - linewidth: width of boundary line
+    """Plot the transformation of a unit circle by the weight matrix W as an ellipse.
+
+    Parameters
+    ----------
+    ax : matplotlib.axes.Axes
+        Matplotlib axis object to plot the ellipse.
+    s : float
+        Scalar to scale the weight matrix W.
+    W : np.ndarray
+        2x2 weight matrix.
+    mean : np.ndarray
+        The center point (x, y) of the ellipse.
+    color : np.ndarray
+        Color of the ellipse.
+    linewidth : int, optional
+        Width of the boundary line, by default 1.
+
     """
     # Compute the transformation matrix
     transform_matrix = W[:2, :2]
@@ -121,7 +174,7 @@ def plot_weight_matrix_as_ellipse(
     circle = np.array([np.cos(theta), np.sin(theta)])  # Unit circle
 
     # Apply the linear transformation to the circle to get an ellipse
-    ellipse = 0.25*s * s * (transform_matrix @ circle)
+    ellipse = 0.25 * s * s * (transform_matrix @ circle)
 
     # Shift the ellipse to the specified mean
     ellipse[0, :] += mean[0]
diff --git a/artlib/cvi/CVIART.py b/artlib/cvi/CVIART.py
index 62cfc81..ad9c495 100644
--- a/artlib/cvi/CVIART.py
+++ b/artlib/cvi/CVIART.py
@@ -1,3 +1,4 @@
+"""CVIART."""
 import numpy as np
 from copy import deepcopy
 import sklearn.metrics as metrics
@@ -7,68 +8,99 @@
 
 
 class CVIART(BaseART):
-    """CVI Art Classification
+    """CVI Art Classification.
 
-    Expanded version of Art that uses Cluster Validity Indicies to help with cluster selection.
-    PBM is not implemented, can be seen here.
-    https://git.mst.edu/acil-group/CVI-Fuzzy-ART/-/blob/master/PBM_index.m?ref_type=heads
+    Expanded version of Art that uses Cluster Validity Indicies to help with cluster
+    selection. PBM is not implemented, can be seen here.
+    git.mst.edu/acil-group/CVI-Fuzzy-ART/-/blob/master/PBM_index.m?ref_type=heads
 
-    Note, the default step_fit function in base ART evaluates the matching function even if
-    the other criteria has failed. This means it could run slower then it would otherwise.
+    Note, the default step_fit function in base ART evaluates the matching function
+    even if the other criteria has failed. This means it could run slower then it would
+    otherwise.
 
-
-    Parameters:
-        rho: float [0,1] for the vigilance parameter.
-        alpha: float choice parameter. 1e-7 recommended value.
-        beta: float [0,1] learning parameters. beta = 1 is fast learning recommended value.
-        validity: int the cluster validity index being used.
-        W: list of weights, top down.
-        labels: class labels for data set.
     """
+
     CALINSKIHARABASZ = 1
     DAVIESBOULDIN = 2
     SILHOUETTE = 3
     # PBM = 4
 
-    def validate_params(self, params: dict):
+    def __init__(self, base_module: BaseART, validity: int):
+        """Initialize the CVIART model.
+
+        Parameters
+        ----------
+        base_module : BaseART
+            Base ART module for clustering.
+        validity : int
+            Validity index used for cluster evaluation.
+
         """
-        validate clustering parameters
+        self.base_module = base_module
+        params = dict(base_module.params, **{"validity": validity})
+        super().__init__(params)
+        print(self.params)
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+    def validate_params(self, params: dict):
+        """Validate clustering parameters.
+
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         self.base_module.validate_params(params)
-        assert 'validity' in params
-        assert isinstance(params['validity'], int)
-        assert params["validity"] in [CVIART.CALINSKIHARABASZ, CVIART.DAVIESBOULDIN, CVIART.SILHOUETTE]
+        assert "validity" in params
+        assert isinstance(params["validity"], int)
+        assert params["validity"] in [
+            CVIART.CALINSKIHARABASZ,
+            CVIART.DAVIESBOULDIN,
+            CVIART.SILHOUETTE,
+        ]
 
     def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+        """Prepare data for clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset to be normalized.
+
+        Returns
+        -------
+        np.ndarray
+            Normalized data.
 
-        Returns:
-            normalized data
         """
         return self.base_module.prepare_data(X)
 
     def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+        """Restore data to state prior to preparation.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset to be restored.
+
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-        Returns:
-            restored data
         """
         return self.base_module.restore_data(X)
 
     @property
-    def W(self):
+    def W(self) -> List:
+        """Get the base module weights.
+
+        Returns
+        -------
+        list of np.ndarray
+            base module weights
+
+        """
         return self.base_module.W
 
     @W.setter
@@ -76,30 +108,53 @@ def W(self, new_W):
         self.base_module.W = new_W
 
     @property
-    def labels_(self):
+    def labels_(self) -> np.ndarray:
+        """Get the base module labels.
+
+        Returns
+        -------
+        np.ndarray
+            base module labels
+
+        """
         return self.base_module.labels_
 
     @labels_.setter
     def labels_(self, new_labels_):
         self.base_module.labels_ = new_labels_
 
-
-    def __init__(self, base_module: BaseART, validity: int):
-        self.base_module = base_module
-        params = dict(base_module.params, **{"validity": validity})
-        super().__init__(params)
-        print(self.params)
-
-
     def CVI_match(self, x, w, c_, params, extra, cache):
+        """Evaluate the cluster validity index (CVI) for a match.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight information.
+        c_ : int
+            Cluster index.
+        params : dict
+            Parameters for the algorithm.
+        extra : dict
+            Extra information including index and validity type.
+        cache : dict
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        bool
+            True if the new validity score improves the clustering, False otherwise.
+
+        """
         if len(self.W) < 2:
             return True
 
-        if extra['validity'] == self.CALINSKIHARABASZ:
+        if extra["validity"] == self.CALINSKIHARABASZ:
             valid_func = metrics.calinski_harabasz_score
-        elif extra['validity'] == self.DAVIESBOULDIN:
+        elif extra["validity"] == self.DAVIESBOULDIN:
             valid_func = metrics.davies_bouldin_score
-        elif extra['validity'] == self.SILHOUETTE:
+        elif extra["validity"] == self.SILHOUETTE:
             valid_func = metrics.silhouette_score
         else:
             raise ValueError(f"Invalid Validity Parameter: {extra['validity']}")
@@ -108,14 +163,40 @@ def CVI_match(self, x, w, c_, params, extra, cache):
         new_labels = np.copy(self.labels_)
         new_labels[extra["index"]] = c_
         new_VI = valid_func(self.data, new_labels)
-        if extra['validity'] != self.DAVIESBOULDIN:
+        if extra["validity"] != self.DAVIESBOULDIN:
             return new_VI > old_VI
         else:
             return new_VI < old_VI
-    def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
+
+    def _match_tracking(
+        self,
+        cache: dict,
+        epsilon: float,
+        params: dict,
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Adjust the vigilance parameter (rho) based on the match tracking method.
+
+        Parameters
+        ----------
+        cache : dict
+            Cache containing match criterion.
+        epsilon : float
+            Epsilon value for adjusting the vigilance parameter.
+        params : dict
+            Parameters for the algorithm.
+        method : {"MT+", "MT-", "MT0", "MT1", "MT~"}
+            Method for resetting match criterion.
+
+        Returns
+        -------
+        bool
+            True if further matching is required, False otherwise.
+
+        """
         M = cache["match_criterion"]
         if method == "MT+":
-            self.base_module.params["rho"] = M+epsilon
+            self.base_module.params["rho"] = M + epsilon
             return True
         elif method == "MT-":
             self.base_module.params["rho"] = M - epsilon
@@ -137,24 +218,33 @@ def _set_params(self, new_params):
     def _deep_copy_params(self) -> dict:
         return deepcopy(self.base_module.params)
 
-    def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: Optional[Callable] = None,
-            max_iter=1, match_reset_method:Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set
-        - y: not used. For compatibility.
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        match_reset_func: Optional[Callable] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        y : np.ndarray, optional
+            Not used. For compatibility.
+        match_reset_func : callable, optional
+            A callable accepting the data sample, a cluster weight, the params dict,
+            and the cache dict.
+            Returns True if the cluster is valid for the sample, False otherwise.
+        max_iter : int, optional
+            Number of iterations to fit the model on the same dataset, by default 1.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion.
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 0.0.
 
         """
         self.data = X
@@ -163,54 +253,138 @@ def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: O
         self.is_fitted_ = True
 
         self.W: list[np.ndarray] = []
-        self.labels_ = np.zeros((X.shape[0], ), dtype=int)
+        self.labels_ = np.zeros((X.shape[0],), dtype=int)
         for _ in range(max_iter):
             for index, x in enumerate(X):
                 self.pre_step_fit(X)
                 if match_reset_func is None:
-                    cvi_match_reset_func = lambda i, w, cluster_a, params, cache: self.CVI_match(i, w, cluster_a, params, {"index": index, "validity":self.params["validity"]}, cache)
+                    cvi_match_reset_func = (
+                        lambda i, w, cluster_a, params, cache: self.CVI_match(
+                            i,
+                            w,
+                            cluster_a,
+                            params,
+                            {
+                                "index": index,
+                                "validity": self.params["validity"],
+                            },
+                            cache,
+                        )
+                    )
                 else:
-                    cvi_match_reset_func = lambda i, w, cluster_a, params, cache: (match_reset_func(i,w,cluster_a,params,cache) and self.CVI_match(i, w, cluster_a, params, {"index": index, "validity":self.params["validity"]}, cache))
-                c = self.base_module.step_fit(x, match_reset_func=cvi_match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
+                    cvi_match_reset_func = lambda i, w, cluster_a, params, cache: (
+                        match_reset_func(i, w, cluster_a, params, cache)
+                        and self.CVI_match(
+                            i,
+                            w,
+                            cluster_a,
+                            params,
+                            {
+                                "index": index,
+                                "validity": self.params["validity"],
+                            },
+                            cache,
+                        )
+                    )
+                c = self.base_module.step_fit(
+                    x,
+                    match_reset_func=cvi_match_reset_func,
+                    match_tracking=match_tracking,
+                    epsilon=epsilon,
+                )
                 self.labels_[index] = c
                 self.post_step_fit(X)
 
-
     def pre_step_fit(self, X: np.ndarray):
+        """Preprocessing step before fitting each sample.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        """
         return self.base_module.pre_step_fit(X)
 
     def post_step_fit(self, X: np.ndarray):
+        """Postprocessing step after fitting each sample.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        """
         return self.base_module.post_step_fit(X)
 
-    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0) -> int:
+    def step_fit(
+        self,
+        x: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ) -> int:
+        """Fit the model to a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        match_reset_func : callable, optional
+            A callable accepting the data sample, a cluster weight, the params dict,
+            and the cache dict.
+            Returns True if the cluster is valid for the sample, False otherwise.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion.
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 0.0.
+
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
+
         """
-        fit the model to a single sample
+        raise NotImplementedError
 
-        Parameters:
-        - x: data sample
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def step_pred(self, x: np.ndarray) -> int:
+        """Predict the label for a single sample.
 
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
 
-        Returns:
-            cluster label of the input sample
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
 
         """
-        raise NotImplementedError
-
-    def step_pred(self, x) -> int:
         return self.base_module.step_pred(x)
 
     def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of the clusters.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
+        """
         return self.base_module.get_cluster_centers()
 
     def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        return self.base_module.plot_cluster_bounds(ax, colors,linewidth)
+        """Plot the boundaries of each cluster.
+
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
+        """
+        return self.base_module.plot_cluster_bounds(ax, colors, linewidth)
diff --git a/artlib/cvi/__init__.py b/artlib/cvi/__init__.py
index e69de29..47f8e87 100644
--- a/artlib/cvi/__init__.py
+++ b/artlib/cvi/__init__.py
@@ -0,0 +1,14 @@
+"""Cluster validity indices are metrics used to evaluate the quality of clustering
+results. These indices help to determine the optimal number of clusters and assess the
+performance of clustering algorithms by measuring the compactness and separation of the
+clusters. Common cluster validity indices include the Silhouette score, Davies-Bouldin
+index, and Dunn index. These indices play an important role in unsupervised learning
+tasks where true labels are not available for evaluation.
+
+This module implements CVI-driven ART modules which utilize the CVI to inform
+clustering; often resulting in objectively superior results.
+
+`Cluster validity indices
+<https://en.wikipedia.org/wiki/Cluster_analysis#Cluster_validity>`_
+
+"""
diff --git a/artlib/cvi/iCVIFuzzyArt.py b/artlib/cvi/iCVIFuzzyArt.py
index e16bf3e..4e3c37a 100644
--- a/artlib/cvi/iCVIFuzzyArt.py
+++ b/artlib/cvi/iCVIFuzzyArt.py
@@ -1,10 +1,21 @@
-"""
-Add Reference in correct format.
-The original matlab code can be found at https://github.com/ACIL-Group/iCVI-toolbox/tree/master
-The formulation is available at
-https://scholarsmine.mst.edu/cgi/viewcontent.cgi?article=3833&context=doctoral_dissertations Pages 314-316 and 319-320
-Extended icvi offline mode can be found at
+"""iCVI Fuzzy ART
+
+.. # da Silva, Leonardo Enzo Brito, Nagasharath Rayapati, and Donald C. Wunsch.
+.. # "iCVI-ARTMAP: using incremental cluster validity indices and adaptive resonance
+.. # theory reset mechanism to accelerate validation and achieve multiprototype
+.. # unsupervised representations."
+.. # IEEE Transactions on Neural Networks and Learning Systems 34.12 (2022): 9757-9770.
+
+The original matlab code can be found at
+https://github.com/ACIL-Group/iCVI-toolbox/tree/master
+ The formulation is available at
+scholarsmine.mst.edu/cgi/viewcontent.cgi?article=3833&context=doctoral_dissertations
+ Pages 314-316 and 319-320 Extended icvi offline mode can be found at
 https://ieeexplore.ieee.org/document/9745260
+
+.. bibliography:: ../../references.bib
+   :filter: citation_key == "da2022icvi"
+
 """
 import numpy as np
 from typing import Optional, Literal, Callable
@@ -13,52 +24,116 @@
 
 
 class iCVIFuzzyART(FuzzyART):
-    """iCVI Fuzzy Art Classification
-
-    Parameters:
-        rho: float [0,1] for the vigilance parameter.
-        alpha: float choice parameter. 1e-7 recommended value.
-        beta: float [0,1] learning parameters. beta = 1 is fast learning recommended value.
-        validity: int the cluster validity index being used.
-        W: list of weights, top down.
-        labels: class labels for data set.
+    """ICVI Fuzzy Art For Clustering.
+
+    .. # da Silva, Leonardo Enzo Brito, Nagasharath Rayapati, and Donald C. Wunsch.
+    .. # "iCVI-ARTMAP: using incremental cluster validity indices and adaptive resonance
+    .. # theory reset mechanism to accelerate validation and achieve multiprototype
+    .. # unsupervised representations."
+    .. # IEEE Transactions on Neural Networks and Learning Systems
+    .. # 34.12 (2022): 9757-9770.
+
+    .. bibliography:: ../../references.bib
+       :filter: citation_key == "da2022icvi"
+
     """
+
     CALINSKIHARABASZ = 1
 
-    def __init__(self, rho: float, alpha: float, beta: float, validity: int, offline: bool = True):
+    def __init__(
+        self,
+        rho: float,
+        alpha: float,
+        beta: float,
+        validity: int,
+        offline: bool = True,
+    ):
+        """Initialize the iCVIFuzzyART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter in the range [0, 1].
+        alpha : float
+            Choice parameter. A value of 1e-7 is recommended.
+        beta : float
+            Learning parameter in the range [0, 1]. A value of 1 is recommended for
+            fast learning.
+        validity : int
+            The cluster validity index being used.
+        offline : bool, optional
+            Whether to use offline mode for iCVI updates, by default True.
+
+        """
         super().__init__(rho, alpha, beta)
-        self.params['validity'] = validity  # Currently not used. Waiting for more algorithms.
+        self.params[
+            "validity"
+        ] = validity  # Currently not used. Waiting for more algorithms.
         self.offline = offline
-        assert 'validity' in self.params  # Because Fuzzy art doesn't accept validity, and makes the params the way it does, validations have to be done after init.
-        assert isinstance(self.params['validity'], int)
+        assert "validity" in self.params
+        assert isinstance(self.params["validity"], int)
 
     def iCVI_match(self, x, w, c_, params, cache):
+        """Apply iCVI (incremental Cluster Validity Index) matching criteria.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight.
+        c_ : int
+            Cluster index.
+        params : dict
+            Dictionary containing algorithm parameters.
+        cache : dict
+            Cache used for storing intermediate results.
+
+        Returns
+        -------
+        bool
+            True if the new criterion value is better than the previous one,
+            False otherwise.
+
+        """
         if self.offline:
             new = self.iCVI.switch_label(x, self.labels_[self.index], c_)
         else:
             new = self.iCVI.add_sample(x, c_)
-        # Eventually this should be an icvi function that you pass the params, and it handles if this is true or false.
-        return new['criterion_value'] > self.iCVI.criterion_value
+        # Eventually this should be an icvi function that you pass the params,
+        # and it handles if this is true or false.
+        return new["criterion_value"] > self.iCVI.criterion_value
         # return self.iCVI.evalLabel(x, c_) This except pass params instead.
 
-    # Could add max epochs back in, but only if offline is true, or do something special...
-    def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: Optional[Callable] = None, max_iter=1, match_reset_method:Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set
-        - y: not used. For compatibility.
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    # Could add max epochs back in, but only if offline is true,
+    # or do something special...
+    def fit(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        match_reset_func: Optional[Callable] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+        y : np.ndarray, optional
+            Not used. For compatibility.
+        match_reset_func : callable, optional
+            A callable accepting the data sample, a cluster weight, the params dict,
+            and the cache dict.
+            Returns True if the cluster is valid for the sample, False otherwise.
+        max_iter : int, optional
+            Number of iterations to fit the model on the same dataset, by default 1.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion.
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 0.0.
 
         """
         self.validate_data(X)
@@ -66,7 +141,7 @@ def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: O
         self.is_fitted_ = True
 
         self.W: list[np.ndarray] = []
-        self.labels_ = np.zeros((X.shape[0], ), dtype=int)
+        self.labels_ = np.zeros((X.shape[0],), dtype=int)
 
         self.iCVI = iCVI_CH(X[0])
 
@@ -79,10 +154,23 @@ def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_func: O
             self.pre_step_fit(X)
             self.index = i
             if match_reset_func is None:
-                c = self.step_fit(x, match_reset_func=self.iCVI_match, match_reset_method=match_reset_method, epsilon=epsilon)
+                c = self.step_fit(
+                    x,
+                    match_reset_func=self.iCVI_match,
+                    match_tracking=match_tracking,
+                    epsilon=epsilon,
+                )
             else:
-                match_reset_func = lambda x, w, c_, params, cache: (match_reset_func(x, w, c_, params, cache) & self.iCVI_match(x, w, c_, params, cache))
-                c = self.step_fit(x, match_reset_func=match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
+                match_reset_func_ = lambda x, w, c_, params, cache: (
+                    match_reset_func(x, w, c_, params, cache)
+                    & self.iCVI_match(x, w, c_, params, cache)
+                )
+                c = self.step_fit(
+                    x,
+                    match_reset_func=match_reset_func_,
+                    match_tracking=match_tracking,
+                    epsilon=epsilon,
+                )
 
             if self.offline:
                 params = self.iCVI.switch_label(x, self.labels_[i], c)
diff --git a/artlib/cvi/iCVIs/CalinkskiHarabasz.py b/artlib/cvi/iCVIs/CalinkskiHarabasz.py
index 6f85989..fb696ee 100644
--- a/artlib/cvi/iCVIs/CalinkskiHarabasz.py
+++ b/artlib/cvi/iCVIs/CalinkskiHarabasz.py
@@ -1,289 +1,378 @@
-"""
-Some things to consider in the future.
+"""Some things to consider in the future.
 
 Removing entire labels from the dataset should be possible.
 
-Right now I think its not possible for Fuzzy Art to delete a cluster, so no need to do so now.
+Right now I think its not possible for Fuzzy Art to delete a cluster, so no need to do
+so now.
+
+Creating functions to explictly add a sample, remove or switch, instead of requiring the
+update call would be nice Or at least change the function names for add, remove, and
+switch, since they imply that its done, not that it will update after update is called.
 
-Creating functions to explictly add a sample, remove or switch, instead of requiring the update call would be nice
-Or at least change the function names for add, remove, and switch, since they imply that its done, not that it will
-update after update is called.
 """
 import numpy as np
+from typing import Dict
+
+
+def delta_add_sample_to_average(
+    average: float, sample: float, total_samples: int
+) -> float:
+    """Calculate the new average if a sample is added.
+
+    Parameters
+    ----------
+    average : float
+        Current average.
+    sample : float
+        New sample to be added.
+    total_samples : int
+        Total number of samples including the new one.
 
+    Returns
+    -------
+    float
+        Updated average after adding the sample.
 
-def delta_add_sample_to_average(average, sample, total_samples):
-    """Calculate the new average if sample is added"""
+    """
     return (sample - average) / total_samples
 
 
-def delta_remove_sample_from_average(average, sample, total_samples):
-    """Calculate the new average if sample is removed"""
+def delta_remove_sample_from_average(
+    average: float, sample: float, total_samples: int
+) -> float:
+    """Calculate the new average if a sample is removed.
+
+    Parameters
+    ----------
+    average : float
+        Current average.
+    sample : float
+        Sample to be removed.
+    total_samples : int
+        Total number of samples before removal.
+
+    Returns
+    -------
+    float
+        Updated average after removing the sample.
+
+    """
     return (average - sample) / (total_samples - 1)
 
 
-class iCVI_CH():
+class iCVI_CH:
     """Implementation of the Calinski Harabasz Validity Index in incremental form.
 
-    Expanded implementation of the incremental version of the Calinski Harabasz Cluster Validity Index.
+    Expanded implementation of the incremental version of the Calinski Harabasz Cluster
+    Validity Index.
 
-    The original matlab code can be found at https://github.com/ACIL-Group/iCVI-toolbox/blob/master/classes/CVI_CH.m
-    The formulation is available at
-    https://scholarsmine.mst.edu/cgi/viewcontent.cgi?article=3833&context=doctoral_dissertations Pages 314-316 and 319-320
+    The original matlab code can be found at
+    https://github.com/ACIL-Group/iCVI-toolbox/blob/master/classes/CVI_CH.m
+     The formulation is available at
+    scholarsmine.mst.edu/cgi/viewcontent.cgi?article=3833&context=doctoral_dissertations
+    Pages 314-316 and 319-320
 
-    This implementation returns a dictionary of updated parameters when calling functions, which can then be passed with
-    the update function to accept the changes. This allows for testing changes/additions to the categories without doing a
-    deep copy of the object.
+    This implementation returns a dictionary of updated parameters when calling
+    functions, which can then be passed with the update function to accept the changes.
+    This allows for testing changes/additions to the categories without doing a deep
+    copy of the object.
 
-    In addition, the calculations for removing a sample, or switching the label of a sample from the dataset are included.
-    This allows for very efficient calculations on clustering algorithms that would like to prune or adjust the labels of
-    samples in the dataset.
+    In addition, the calculations for removing a sample, or switching the label of a
+    sample from the dataset are included. This allows for very efficient calculations
+    on clustering algorithms that would like to prune or adjust the labels of samples
+    in the dataset.
 
     For the Calinski Harabasz validity Index, larger values represent better clusters.
 
-    Parameters:
-        dim: an int storing the dimensionality of the input data.
-        n_samples: an int with the total number of samples.
-        mu: a numpy array representing the average of all data points.
-        CD: ClusterData... a dict holding all the data for each cluster, with the parameters
-            n: the number of samples belonging to each cluster/label.
-            v: the prototypes/centriods of each cluster.
-            CP: the compactness of each cluster. Not really needed since WGSS can be calculated incrementally.
-            G: the vector g of each cluster.
-        WGSS: Within Groupd sum of squares.
-        criterion_value: the calculated CH score.
     """
 
     def __init__(self, x: np.ndarray) -> None:
-        """Create the iCVI object.
+        """Create the iCVI_CH object.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            A sample from the dataset used for recording data dimensionality.
 
-        Args:
-            x: a sample from the dataset used for recording data dimensionality.
         """
         self.dim = x.shape[0]  # Dimension of the input data
         self.n_samples: int = 0  # number of samples encountered
         self.mu = np.array([])  # geometric mean of the data
-        self.CD = {}            # Dict for each cluster label containing n,v,CP, and G
-        self.WGSS = 0           # within group sum of squares
+        self.CD: Dict = {}  # Dict for each cluster label containing n,v,CP, and G
+        self.WGSS = 0  # within group sum of squares
         self.criterion_value = 0  # calcualted CH index
 
-    def add_sample(self, x, label) -> dict:
+    def add_sample(self, x: np.ndarray, label: int) -> Dict:
         """Calculate the result of adding a new sample with a given label.
 
-        Create a dictionary containing the updated values after assigning a label to a given sample.
-        To accept the outcome of the sample being added, pass the returned parameter dict to update.
+        Parameters
+        ----------
+        x : np.ndarray
+            The sample to add to the current validity index calculation.
+        label : int
+            The sample category/cluster.
 
-        In general, if newP['criterion_value'] > obj.criterion_value, the clustering has been improved.
+        Returns
+        -------
+        dict
+            A dictionary containing the updated values after the sample is added.
 
-        Args:
-            x: The sample to add to the current validity index calculation
-            label: an int representing the sample category/cluster
-
-        Returns:
-            newP: a dictionary contained the values after the sample is added, to be passed to update call.
         """
-        newP = {'x': x, 'label': label}  # New Parameters
-        newP['n_samples'] = self.n_samples + 1
+        newP = {"x": x, "label": label}  # New Parameters
+        newP["n_samples"] = self.n_samples + 1
         if self.mu.size == 0:  # mu will be size 0 if no samples in dataset.
-            newP['mu'] = x
+            newP["mu"] = x
         else:
-            newP['mu'] = self.mu + delta_add_sample_to_average(self.mu, x, newP['n_samples'])
+            newP["mu"] = self.mu + delta_add_sample_to_average(
+                self.mu, x, newP["n_samples"]
+            )
 
-        CD = {}
-        newP['CD'] = CD
+        CD: Dict = {}
+        newP["CD"] = CD
         SEP = []  # separation between each cluster and the mean of the data
         if label not in self.CD:
             n_clusters = len(self.CD) + 1
-            CD['n'] = 1
-            CD['v'] = x
-            CD['CP'] = 0
-            CD['G'] = np.zeros(self.dim)
-            newP['CP_diff'] = 0
-            diff = x - newP['mu']
-            SEP.append(sum(diff ** 2))  # Handle SEP for new clusters now instead of later.
+            CD["n"] = 1
+            CD["v"] = x
+            CD["CP"] = 0
+            CD["G"] = np.zeros(self.dim)
+            newP["CP_diff"] = 0
+            diff = x - newP["mu"]
+            SEP.append(
+                sum(diff**2)
+            )  # Handle SEP for new clusters now instead of later.
         else:
             n_clusters = len(self.CD)
             Data = self.CD[label]
-            CD['n'] = Data['n'] + 1
-
-            # The paper defines deltaV = Vold - Vnew, so I need to switch this sign. Consider changing functions to do this.
-            deltaV = -1 * delta_add_sample_to_average(Data['v'], x, CD['n'])
-            CD['v'] = Data['v'] - deltaV  # Vnew = Vold - deltaV
-            diff_x_v = x - CD['v']
-
-            newP['CP_diff'] = (diff_x_v @ diff_x_v.T) + (CD['n'] - 1) * (deltaV @ deltaV.T) + 2 * (deltaV @ Data['G'])
-            CD['CP'] = Data['CP'] + newP['CP_diff']
-            CD['G'] = Data['G'] + diff_x_v + (CD['n'] - 1) * deltaV
+            CD["n"] = Data["n"] + 1
+
+            # The paper defines deltaV = Vold - Vnew, so I need to switch this sign.
+            # Consider changing functions to do this.
+            deltaV = -1 * delta_add_sample_to_average(Data["v"], x, CD["n"])
+            CD["v"] = Data["v"] - deltaV  # Vnew = Vold - deltaV
+            diff_x_v = x - CD["v"]
+
+            newP["CP_diff"] = (
+                (diff_x_v @ diff_x_v.T)
+                + (CD["n"] - 1) * (deltaV @ deltaV.T)
+                + 2 * (deltaV @ Data["G"])
+            )
+            CD["CP"] = Data["CP"] + newP["CP_diff"]
+            CD["G"] = Data["G"] + diff_x_v + (CD["n"] - 1) * deltaV
 
         if n_clusters < 2:
-            newP['criterion_value'] = 0
+            newP["criterion_value"] = 0
         else:
             for i in self.CD:  # A new label won't be processed, but handled earlier
                 if i == label:
-                    n = CD['n']
-                    v = CD['v']
+                    n = CD["n"]
+                    v = CD["v"]
                 else:
-                    n = self.CD[i]['n']
-                    v = self.CD[i]['v']
-                diff = v - newP['mu']
-                SEP.append(n * sum(diff ** 2))
+                    n = self.CD[i]["n"]
+                    v = self.CD[i]["v"]
+                diff = v - newP["mu"]
+                SEP.append(n * sum(diff**2))
 
-            WGSS = self.WGSS + newP['CP_diff']
+            WGSS = self.WGSS + newP["CP_diff"]
             BGSS = sum(SEP)  # between-group sum of squares
-            if WGSS == 0:  # this can be 0 if all samples in different clusters, which is a divide by 0 error.
-                newP['criterion_value'] = 0
+            if WGSS == 0:  # this can be 0 if all samples in different clusters,
+                # which is a divide by 0 error.
+                newP["criterion_value"] = 0
             else:
-                newP['criterion_value'] = (BGSS / WGSS) * (newP['n_samples'] - n_clusters) / (n_clusters - 1)
+                newP["criterion_value"] = (
+                    (BGSS / WGSS) * (newP["n_samples"] - n_clusters) / (n_clusters - 1)
+                )
         return newP
 
-    def update(self, params) -> None:
-        """Update the parameters of the object.
+    def update(self, params: dict) -> None:
+        """Update the parameters of the object. Takes the updated params from
+        adding/removing a sample or switching its label, and updates the object.
+        Switching a label needs more updates, so those dicts have an extra set of things
+        to update, signified with the 'label2' key existing.
 
-        Takes the updated params from adding/removing a sample or switching its label, and updates the object.
-        Switching a label needs more updates, so those dicts have an extra set of things to update, signified with
-        the 'label2' key existing
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing the updated parameters to be applied.
 
-        Args:
-            params: dict containing the parameters to update.
         """
-        self.n_samples = params['n_samples']
-        self.mu = params['mu']
-        self.criterion_value = params['criterion_value']
-        self.CD[params['label']] = params['CD']
-        self.WGSS += params['CP_diff']
-        if 'label2' in params:
-            self.CD[params['label2']] = params['CD2']
-            self.WGSS += params['CP_diff2']
-
-    def switch_label(self, x, label_old, label_new):
-        """Calculates the parameters if a sample has its label changed.
-
-        This essentially removes a sample with the old label from the clusters, then adds it back with the new sample.
-        There are a few optimizations, such as keeping mu the same since adding and removing it doesn't affect any calculations
+        self.n_samples = params["n_samples"]
+        self.mu = params["mu"]
+        self.criterion_value = params["criterion_value"]
+        self.CD[params["label"]] = params["CD"]
+        self.WGSS += params["CP_diff"]
+        if "label2" in params:
+            self.CD[params["label2"]] = params["CD2"]
+            self.WGSS += params["CP_diff2"]
+
+    def switch_label(self, x: np.ndarray, label_old: int, label_new: int) -> dict:
+        """Calculate the parameters when a sample has its label changed.
+
+        This essentially removes a sample with the old label from the clusters, then
+        adds it back with the new sample. There are a few optimizations, such as
+        keeping mu the same since adding and removing it doesn't affect any calculations
         that are needed.
 
-        Otherwise it should work the same as removing a sample and updating, then adding the sample back and updating, without
-        the need to create a deep copy of the object if just testing the operation.
+        Otherwise it should work the same as removing a sample and updating, then
+        adding the sample back and updating, without the need to create a deep copy of
+        the object if just testing the operation.
 
-        Args:
-            x: The sample to switch the label of for the current validity index calculation
-            label_old: an int representing the sample category/cluster the sample belongs to
-            label_new: an int representing the sample category/cluster the sample will be assigned to
+        Parameters
+        ----------
+        x : np.ndarray
+            The sample whose label is being changed.
+        label_old : int
+            The old label of the sample.
+        label_new : int
+            The new label of the sample.
 
-        Returns:
-            newP: a dictionary contained the values after the sample is added, to be passed to update call."""
+        Returns
+        -------
+        dict
+            A dictionary containing the updated values after switching the label.
+
+        """
         if label_new == label_old:
-            return {'n_samples': self.n_samples,
-                    'mu': self.mu,
-                    'criterion_value': self.criterion_value,
-                    'label': label_old,
-                    'CP_diff': 0,
-                    'CD': {
-                        'n': self.CD[label_old]['n'],
-                        'v': self.CD[label_old]['v'],
-                        'CP': self.CD[label_old]['CP'],
-                        'G': self.CD[label_old]['G']}
-                    }
-        if self.CD[label_old]['n'] <= 1:
+            return {
+                "n_samples": self.n_samples,
+                "mu": self.mu,
+                "criterion_value": self.criterion_value,
+                "label": label_old,
+                "CP_diff": 0,
+                "CD": {
+                    "n": self.CD[label_old]["n"],
+                    "v": self.CD[label_old]["v"],
+                    "CP": self.CD[label_old]["CP"],
+                    "G": self.CD[label_old]["G"],
+                },
+            }
+        if self.CD[label_old]["n"] <= 1:
             raise Exception("Can't remove a value from a cluster of 1")
 
-        newP = {'x': x, 'label': label_old, 'label2': label_new}  # New Parameters
-        newP['mu'] = self.mu
-        newP['n_samples'] = self.n_samples
+        newP = {
+            "x": x,
+            "label": label_old,
+            "label2": label_new,
+        }  # New Parameters
+        newP["mu"] = self.mu
+        newP["n_samples"] = self.n_samples
         params_remove = self.remove_sample(x, label_old)
         params_add = self.add_sample(x, label_new)
-        newP['CD'] = params_remove['CD']
-        newP['CP_diff'] = params_remove['CP_diff']
-        newP['CD2'] = params_add['CD']
-        newP['CP_diff2'] = params_add['CP_diff']
+        newP["CD"] = params_remove["CD"]
+        newP["CP_diff"] = params_remove["CP_diff"]
+        newP["CD2"] = params_add["CD"]
+        newP["CP_diff2"] = params_add["CP_diff"]
 
         SEP = []  # separation between each cluster and the mean of the data
         if label_new not in self.CD:
             n_clusters = len(self.CD) + 1
-            diff = x - newP['mu']
-            SEP.append(sum(diff ** 2))  # Handle SEP for new clusters now instead of later.
+            diff = x - newP["mu"]
+            SEP.append(
+                sum(diff**2)
+            )  # Handle SEP for new clusters now instead of later.
         else:
             n_clusters = len(self.CD)
 
-        if n_clusters < 2:  # I don't think this can ever happen with remove label not deleting categories.
-            newP['criterion_value'] = 0
+        if n_clusters < 2:  # I don't think this can ever happen
+            newP["criterion_value"] = 0
         else:
             for i in self.CD:
                 if i == label_old:
-                    n = params_remove['CD']['n']
-                    v = params_remove['CD']['v']
+                    n = params_remove["CD"]["n"]
+                    v = params_remove["CD"]["v"]
                 elif i == label_new:
-                    n = params_add['CD']['n']
-                    v = params_add['CD']['v']
+                    n = params_add["CD"]["n"]
+                    v = params_add["CD"]["v"]
                 else:
-                    n = self.CD[i]['n']
-                    v = self.CD[i]['v']
-                diff = v - newP['mu']
-                SEP.append(n * sum(diff ** 2))
+                    n = self.CD[i]["n"]
+                    v = self.CD[i]["v"]
+                diff = v - newP["mu"]
+                SEP.append(n * sum(diff**2))
 
-            WGSS = self.WGSS + newP['CP_diff']
-            WGSS += newP['CP_diff2']
+            WGSS = self.WGSS + newP["CP_diff"]
+            WGSS += newP["CP_diff2"]
             BGSS = sum(SEP)  # between-group sum of squares
-            if WGSS == 0:  # this can be 0 if all samples in different clusters, which is a divide by 0 error.
-                newP['criterion_value'] = 0
+            if WGSS == 0:  # this can be 0 if all samples in different clusters,
+                # which is a divide by 0 error.
+                newP["criterion_value"] = 0
             else:
-                newP['criterion_value'] = (BGSS / WGSS) * (newP['n_samples'] - n_clusters) / (n_clusters - 1)
+                newP["criterion_value"] = (
+                    (BGSS / WGSS) * (newP["n_samples"] - n_clusters) / (n_clusters - 1)
+                )
         return newP
 
-    def remove_sample(self, x, label):  # This is left here mostly as an extra, and not really meant to be used.
-        """Remove a sample from the clusters
+    def remove_sample(
+        self, x: np.ndarray, label: int
+    ) -> dict:  # This is left here mostly as an extra, and not really meant to be used.
+        """Remove a sample from the clusters.
 
-         Calculates parameters after removing a sample from the clusters, or the opposite of an add operation.
+        Parameters
+        ----------
+        x : np.ndarray
+            The sample to remove from the current validity index calculation.
+        label : int
+            The sample category/cluster.
 
-        Args:
-            x: The sample to remove from the current validity index calculation
-            label: an int representing the sample category/cluster
+        Returns
+        -------
+        dict
+            A dictionary containing the updated values after the sample is removed.
 
-        Returns:
-            newP: a dictionary contained the values after the sample is remove, to be passed to update call.
         """
         Data = self.CD[label]
-        if Data['n'] <= 1:
-            raise Exception("Can't remove a value from a cluster of 1")  # At least for now
+        if Data["n"] <= 1:
+            raise Exception(
+                "Can't remove a value from a cluster of 1"
+            )  # At least for now
 
-        newP = {'x': x, 'label': label}  # New Parameters after removal
-        newP['mu'] = self.mu - delta_remove_sample_from_average(self.mu, x, self.n_samples)
-        newP['n_samples'] = self.n_samples - 1
+        newP = {"x": x, "label": label}  # New Parameters after removal
+        newP["mu"] = self.mu - delta_remove_sample_from_average(
+            self.mu, x, self.n_samples
+        )
+        newP["n_samples"] = self.n_samples - 1
 
         CD = {}
-        newP['CD'] = CD
-        CD['n'] = Data['n'] - 1
+        newP["CD"] = CD
+        CD["n"] = Data["n"] - 1
 
-        # We need the delta v from when the sample was added, but the paper defines deltaV = Vold - Vnew, so I need to keep these signs
-        deltaVPrior = delta_remove_sample_from_average(Data['v'], x, Data['n'])
-        CD['v'] = Data['v'] + deltaVPrior  # Vnew + deltaV = Vold
-        diff_x_vPrior = x - Data['v']
+        # We need the delta v from when the sample was added,
+        # but the paper defines deltaV = Vold - Vnew, so I need to keep these signs
+        deltaVPrior = delta_remove_sample_from_average(Data["v"], x, Data["n"])
+        CD["v"] = Data["v"] + deltaVPrior  # Vnew + deltaV = Vold
+        diff_x_vPrior = x - Data["v"]
 
         # We already subtracted 1 from n
-        CD['G'] = Data['G'] - (diff_x_vPrior + (CD['n']) * deltaVPrior)
+        CD["G"] = Data["G"] - (diff_x_vPrior + (CD["n"]) * deltaVPrior)
         # CD's G is the old G, which is what we added before.
-        newP['CP_diff'] = -1 * ((diff_x_vPrior @ diff_x_vPrior.T) + (CD['n']) * (deltaVPrior @ deltaVPrior.T) + 2 * (deltaVPrior @ CD['G']))
-        CD['CP'] = Data['CP'] + newP['CP_diff']
+        newP["CP_diff"] = -1 * (
+            (diff_x_vPrior @ diff_x_vPrior.T)
+            + (CD["n"]) * (deltaVPrior @ deltaVPrior.T)
+            + 2 * (deltaVPrior @ CD["G"])
+        )
+        CD["CP"] = Data["CP"] + newP["CP_diff"]
 
         n_clusters = len(self.CD)  # Move this up if deleting clusters are allowed.
         if n_clusters < 2:
-            newP['criterion_value'] = 0
+            newP["criterion_value"] = 0
         else:
             SEP = []  # separation between each cluster and the mean of the data
             for i in self.CD:
                 if i == label:
-                    n = CD['n']
-                    v = CD['v']
+                    n = CD["n"]
+                    v = CD["v"]
                 else:
-                    n = self.CD[i]['n']
-                    v = self.CD[i]['v']
-                diff = v - newP['mu']
-                SEP.append(n * sum(diff ** 2))
+                    n = self.CD[i]["n"]
+                    v = self.CD[i]["v"]
+                diff = v - newP["mu"]
+                SEP.append(n * sum(diff**2))
 
-            WGSS = self.WGSS + newP['CP_diff']
+            WGSS = self.WGSS + newP["CP_diff"]
             BGSS = sum(SEP)  # between-group sum of squares
-            if WGSS == 0:  # this can be 0 if all samples in different clusters, which is a divide by 0 error.
-                newP['criterion_value'] = 0
+            if WGSS == 0:  # this can be 0 if all samples in different clusters,
+                # which is a divide by 0 error.
+                newP["criterion_value"] = 0
             else:
-                newP['criterion_value'] = (BGSS / WGSS) * (newP['n_samples'] - n_clusters) / (n_clusters - 1)
+                newP["criterion_value"] = (
+                    (BGSS / WGSS) * (newP["n_samples"] - n_clusters) / (n_clusters - 1)
+                )
         return newP
diff --git a/artlib/cvi/iCVIs/__init__.py b/artlib/cvi/iCVIs/__init__.py
new file mode 100644
index 0000000..8d48a25
--- /dev/null
+++ b/artlib/cvi/iCVIs/__init__.py
@@ -0,0 +1,1 @@
+"""Implementations of common iCVIs."""
diff --git a/artlib/elementary/ART1.py b/artlib/elementary/ART1.py
index 2a04404..2c7e78e 100644
--- a/artlib/elementary/ART1.py
+++ b/artlib/elementary/ART1.py
@@ -1,138 +1,191 @@
-"""
-Carpenter, G. A., & Grossberg, S. (1987a).
-A massively parallel architecture for a self-organizing neural pattern recognition machine.
-Computer Vision, Graphics, and Image Processing, 37, 54 – 115. doi:10. 1016/S0734-189X(87)80014-2.
-"""
+"""ART1 :cite:`carpenter1987massively`."""
+
+# Carpenter, G. A., & Grossberg, S. (1987a).
+# A massively parallel architecture for a self-organizing neural pattern
+# recognition machine.
+# Computer Vision, Graphics, and Image
+# Processing, 37, 54 – 115. doi:10. 1016/S0734-189X(87)80014-2.
 
 import numpy as np
-from typing import Optional, List
+from typing import Optional, List, Tuple, Dict
 from artlib.common.BaseART import BaseART
 from artlib.common.utils import l1norm
 
 
 class ART1(BaseART):
-    # implementation of ART 1
-    def __init__(self, rho: float, beta: float, L: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - beta: learning rate
-        - L: uncommitted node bias
+    """ART1 for Binary Clustering.
+
+    This module implements ART1 as first published in: :cite:`carpenter1987massively`.
+
+
+    .. # Carpenter, G. A., & Grossberg, S. (1987a).
+    .. # A massively parallel architecture for a self-organizing neural pattern
+    .. # recognition machine.
+    .. # Computer Vision, Graphics, and Image
+    .. # Processing, 37, 54 – 115. doi:10. 1016/S0734-189X(87)80014-2.
+
+    ART1 is exclusively for clustering binary data.
+
+    """
+
+    def __init__(self, rho: float, L: float):
+        """Initialize the ART1 model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter in the range [0, 1].
+        L : float
+            Uncommitted node bias, a value greater than or equal to 1.
 
         """
-        params = {
-            "rho": rho,
-            "beta": beta,
-            "L": L
-        }
+        params = {"rho": rho, "L": L}
         super().__init__(params)
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
-        assert "beta" in params
         assert "L" in params
-        assert 1. >= params["rho"] >= 0.
-        assert 1. >= params["beta"] >= 0.
-        assert params["L"] >= 1.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert params["L"] >= 1.0
         assert isinstance(params["rho"], float)
-        assert isinstance(params["beta"], float)
         assert isinstance(params["L"], float)
 
     def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the data prior to clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         assert np.array_equal(X, X.astype(bool)), "ART1 only supports binary data"
         self.check_dimensions(X)
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
-
-        """
-        w_bu = w[:self.dim_]
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
+
+        """
+        w_bu = w[: self.dim_]
         return float(np.dot(i, w_bu)), None
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
-
-        """
-        w_td = w[self.dim_:]
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
+
+        """
+        w_td = w[self.dim_ :]
         return l1norm(np.logical_and(i, w_td)) / l1norm(i), cache
 
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
-
-        """
-        w_td = w[self.dim_:]
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
+
+        """
+        w_td = w[self.dim_ :]
 
         w_td_new = np.logical_and(i, w_td)
-        w_bu_new = (params["L"] / (params["L"] - 1 + l1norm(w_td_new)))*w_td_new
+        w_bu_new = (params["L"] / (params["L"] - 1 + l1norm(w_td_new))) * w_td_new
         return np.concatenate([w_bu_new, w_td_new])
 
-
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         w_td_new = i
-        w_bu_new = (params["L"] / (params["L"] - 1 + self.dim_))*w_td_new
+        w_bu_new = (params["L"] / (params["L"] - 1 + self.dim_)) * w_td_new
         return np.concatenate([w_bu_new, w_td_new])
 
     def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return [w[self.dim_:] for w in self.W]
+        return [w[self.dim_ :] for w in self.W]
diff --git a/artlib/elementary/ART2.py b/artlib/elementary/ART2.py
index 497e0a0..e734410 100644
--- a/artlib/elementary/ART2.py
+++ b/artlib/elementary/ART2.py
@@ -1,21 +1,25 @@
-"""
-Carpenter, G. A., & Grossberg, S. (1987b).
-ART 2: self-organization of stable category recognition codes for analog input patterns.
-Appl. Opt., 26, 4919–4930. doi:10.1364/AO.26.004919.
+"""ART2 :cite:`carpenter1987art`, :cite:`carpenter1991art`.
 
-Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991b).
-ART 2-A: An adaptive resonance algorithm for rapid category learning and recognition.
-Neural Networks, 4, 493 – 504. doi:10.1016/0893-6080(91) 90045-7.
-"""
+::
+
+    ==================================================================
+    DISCLAIMER: DO NOT USE ART2!!!
+    IT DOES NOT WORK
+    It is provided for completeness only.
+    Stephan Grossberg himself has said ART2 does not work.
+    ==================================================================
 
 """
-==================================================================
-DISCLAIMER: DO NOT USE ART2!!!
-IT DOES NOT WORK
-It is provided for completeness only.
-Stephan Grossberg himself has said ART2 does not work.
-==================================================================
-"""
+
+# Carpenter, G. A., & Grossberg, S. (1987b).
+# ART 2: self-organization of stable category recognition codes for analog input
+# patterns.
+# Appl. Opt., 26, 4919–4930. doi:10.1364/AO.26.004919.
+
+# Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991b).
+# ART 2-A: An adaptive resonance algorithm for rapid category learning and
+# recognition.
+# Neural Networks, 4, 493 – 504. doi:10.1016/0893-6080(91) 90045-7.
 
 import numpy as np
 from typing import Optional, List
@@ -24,16 +28,44 @@
 
 
 class ART2A(BaseART):
-    # implementation of ART 2-A
+    """ART2-A for Clustering.
+
+    This module implements ART2-A as first published in:
+    :cite:`carpenter1987art`, :cite:`carpenter1991art`
+
+
+    .. # Carpenter, G. A., & Grossberg, S. (1987b).
+    .. # ART 2: self-organization of stable category recognition codes for analog input
+    .. # patterns.
+    .. # Appl. Opt., 26, 4919–4930. doi:10.1364/AO.26.004919.
+
+    .. # Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991b).
+    .. # ART 2-A: An adaptive resonance algorithm for rapid category learning and
+    .. # recognition.
+    .. # Neural Networks, 4, 493 – 504. doi:10.1016/0893-6080(91) 90045-7.
+
+
+    ART2-A is similar to :class:`~artlib.elementary.ART1.ART1` but designed for analog
+    data. This method is implemented for historical purposes and is not recommended
+    for use.
+
+    """
+
     def __init__(self, rho: float, alpha: float, beta: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - alpha: choice parameter
-        - beta: learning rate
+        """Initialize the ART2-A model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter in the range [0, 1].
+        alpha : float
+            Choice parameter, recommended value is 1e-7.
+        beta : float
+            Learning parameter in the range [0, 1]. A value of 1 is recommended for
+            fast learning.
 
         """
-        warn("Do Not Use ART2. It does not work. This module is provided for completeness only")
+        warn("Do Not Use ART2. It does not work. Module provided for completeness only")
 
         params = {
             "rho": rho,
@@ -44,29 +76,31 @@ def __init__(self, rho: float, alpha: float, beta: float):
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
         assert "alpha" in params
         assert "beta" in params
-        assert 1. >= params["rho"] >= 0.
-        assert 1. >= params["alpha"] >= 0.
-        assert 1. >= params["beta"] >= 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert 1.0 >= params["alpha"] >= 0.0
+        assert 1.0 >= params["beta"] >= 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["alpha"], float)
         assert isinstance(params["beta"], float)
-        
+
     def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
+        """Check that the data has the correct dimensions.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         if not hasattr(self, "dim_"):
@@ -74,85 +108,125 @@ def check_dimensions(self, X: np.ndarray):
             assert self.params["alpha"] <= 1 / np.sqrt(self.dim_)
         else:
             assert X.shape[1] == self.dim_
-        
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
 
-        Returns:
-            cluster activation, cache used for later processing
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
         activation = float(np.dot(i, w))
         cache = {"activation": activation}
         return activation, cache
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> tuple[float, Optional[dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         if cache is None:
             raise ValueError("No cache provided")
         # TODO: make this more efficient
         M = cache["activation"]
-        M_u = params["alpha"]*np.sum(i)
+        M_u = params["alpha"] * np.sum(i)
         # suppress if uncommitted activation is higher
         if M < M_u:
-            return -1., cache
+            return -1.0, cache
         else:
             return M, cache
 
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
-
-        """
-        return params["beta"]*i + (1-params["beta"])*w
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
+
+        """
+        return params["beta"] * i + (1 - params["beta"]) * w
 
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         return i
 
     def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return self.W
\ No newline at end of file
+        return self.W
diff --git a/artlib/elementary/BayesianART.py b/artlib/elementary/BayesianART.py
index d092384..aadc2b3 100644
--- a/artlib/elementary/BayesianART.py
+++ b/artlib/elementary/BayesianART.py
@@ -1,10 +1,11 @@
-"""
-Vigdor, B., & Lerner, B. (2007).
-The Bayesian ARTMAP.
-IEEE Transactions on Neural Networks, 18, 1628–1644. doi:10.1109/TNN.2007.900234.
-"""
+"""Bayesian ART :cite:`vigdor2007bayesian`."""
+# Vigdor, B., & Lerner, B. (2007).
+# The Bayesian ARTMAP.
+# IEEE Transactions on Neural
+# Networks, 18, 1628–1644. doi:10.1109/TNN.2007.900234.
+
 import numpy as np
-from typing import Optional, Iterable, List, Callable, Literal, Tuple
+from typing import Optional, Iterable, List, Callable, Literal, Tuple, Union, Dict
 import operator
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
@@ -12,13 +13,33 @@
 
 
 class BayesianART(BaseART):
-    # implementation of Bayesian ART
+    """Bayesian ART for Clustering.
+
+    This module implements Bayesian ART as first published in:
+    :cite:`vigdor2007bayesian`.
+
+     .. # Vigdor, B., & Lerner, B. (2007).
+    .. # The Bayesian ARTMAP.
+    .. # IEEE Transactions on Neural
+    .. # Networks, 18, 1628–1644. doi:10.1109/TNN.2007.900234.
+
+    Bayesian ART clusters data in Bayesian Distributions (Hyper-ellipsoids) and is
+    similar to :class:`~artlib.elementary.GaussianART.GaussianART` but differs in that
+    it allows arbitrary rotation of the hyper-ellipsoid.
+
+    """
+
     pi2 = np.pi * 2
+
     def __init__(self, rho: float, cov_init: np.ndarray):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - cov_init: initial estimate of covariance matrix
+        """Initialize the Bayesian ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter in the range [0, 1].
+        cov_init : np.ndarray
+            Initial estimate of the covariance matrix for each cluster.
 
         """
         params = {
@@ -29,11 +50,12 @@ def __init__(self, rho: float, cov_init: np.ndarray):
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
@@ -43,11 +65,12 @@ def validate_params(params: dict):
         assert isinstance(params["cov_init"], np.ndarray)
 
     def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
+        """Check that the data has the correct dimensions.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
 
         """
         if not hasattr(self, "dim_"):
@@ -57,79 +80,120 @@ def check_dimensions(self, X: np.ndarray):
         else:
             assert X.shape[1] == self.dim_
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
-        mean = w[:self.dim_]
-        cov = w[self.dim_:-1].reshape((self.dim_, self.dim_))
+        mean = w[: self.dim_]
+        cov = w[self.dim_ : -1].reshape((self.dim_, self.dim_))
         n = w[-1]
         dist = mean - i
 
-        exp_dist_cov_dist = np.exp(-0.5 * np.matmul(dist.T, np.matmul(np.linalg.inv(cov), dist)))
+        exp_dist_cov_dist = np.exp(
+            -0.5 * np.matmul(dist.T, np.matmul(np.linalg.inv(cov), dist))
+        )
         det_cov = np.linalg.det(cov)
 
-        p_i_cj = exp_dist_cov_dist / np.sqrt((self.pi2 ** self.dim_) * det_cov)
+        p_i_cj = exp_dist_cov_dist / np.sqrt((self.pi2**self.dim_) * det_cov)
         p_cj = n / np.sum(w_[-1] for w_ in self.W)
 
         activation = p_i_cj * p_cj
 
-        cache = {
-            "cov": cov,
-            "det_cov": det_cov
-        }
+        cache = {"cov": cov, "det_cov": det_cov}
 
         return activation, cache
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         # the original paper uses the det(cov_old) for match criterion
-        # however, it makes logical sense to use the new_cov and results are improved when doing so
+        # however, it makes logical sense to use the new_cov and results are
+        # improved when doing so
+        assert cache is not None
         new_w = self.update(i, w, params, cache)
-        new_cov = new_w[self.dim_:-1].reshape((self.dim_, self.dim_))
+        new_cov = new_w[self.dim_ : -1].reshape((self.dim_, self.dim_))
         cache["new_w"] = new_w
-        # if cache is None:
-        #     raise ValueError("No cache provided")
         # return cache["det_cov"]
         return np.linalg.det(new_cov), cache
 
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, op: Callable = operator.ge) -> tuple[bool, dict]:
-        """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion binary, cache used for later processing
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+        op: Callable = operator.ge,
+    ) -> tuple[bool, dict]:
+        """Get the binary match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+        op : callable, optional
+            Operator for comparison, by default operator.ge.
+
+        Returns
+        -------
+        bool
+            Binary match criterion.
+        dict
+            Cache used for later processing.
 
         """
         M, cache = self.match_criterion(i, w, params=params, cache=cache)
-        M_bin = op(params["rho"], M) # note that this is backwards from the base ART: rho >= M
+        M_bin = op(
+            params["rho"], M
+        )  # note that this is backwards from the base ART: rho >= M
         if cache is None:
             cache = dict()
         cache["match_criterion"] = M
@@ -137,9 +201,37 @@ def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache:
 
         return M_bin, cache
 
-    def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
+    def _match_tracking(
+        self,
+        cache: Union[List[Dict], Dict],
+        epsilon: float,
+        params: Union[List[Dict], Dict],
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Adjust match tracking based on the method and epsilon value.
+
+        Parameters
+        ----------
+        cache : dict
+            Cache containing intermediate results, including the match criterion.
+        epsilon : float
+            Adjustment factor for the match criterion.
+        params : dict
+            Dictionary containing algorithm parameters.
+        method : {"MT+", "MT-", "MT0", "MT1", "MT~"}
+            Match tracking method to use.
+
+        Returns
+        -------
+        bool
+            True if match tracking continues, False otherwise.
+
+        """
+        assert isinstance(cache, dict)
+        assert isinstance(params, dict)
         M = cache["match_criterion"]
-        # we have to reverse some signs becayse bayesianART has an inverted vigilence check
+        # we have to reverse some signs because bayesianART has an inverted
+        # vigilence check
         if method == "MT+":
             self.params["rho"] = M - epsilon
             return True
@@ -157,75 +249,95 @@ def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Lit
         else:
             raise ValueError(f"Invalid Match Tracking Method: {method}")
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
-        if cache is None:
-            raise ValueError("No cache provided")
+        assert cache is not None
 
         if "new_w" in cache:
             return cache["new_w"]
 
-        mean = w[:self.dim_]
-        cov = w[self.dim_:-1].reshape((self.dim_, self.dim_))
+        mean = w[: self.dim_]
+        cov = w[self.dim_ : -1].reshape((self.dim_, self.dim_))
         n = w[-1]
 
-        n_new = n+1
-        mean_new = (1-(1/n_new))*mean + (1/n_new)*i
+        n_new = n + 1
+        mean_new = (1 - (1 / n_new)) * mean + (1 / n_new) * i
 
-        i_mean_dist = i-mean_new
-        i_mean_dist_2 = i_mean_dist.reshape((-1, 1))*i_mean_dist.reshape((1, -1))
+        i_mean_dist = i - mean_new
+        i_mean_dist_2 = i_mean_dist.reshape((-1, 1)) * i_mean_dist.reshape((1, -1))
 
         cov_new = (n / n_new) * cov + (1 / n_new) * i_mean_dist_2
 
         return np.concatenate([mean_new, cov_new.flatten(), [n_new]])
 
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         return np.concatenate([i, params["cov_init"].flatten(), [1]])
 
     def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return [w[:self.dim_] for w in self.W]
+        return [w[: self.dim_] for w in self.W]
 
     def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         for w, col in zip(self.W, colors):
-            mean = w[:self.dim_]
-            cov = w[self.dim_:-1].reshape((self.dim_, self.dim_))
+            mean = w[: self.dim_]
+            cov = w[self.dim_ : -1].reshape((self.dim_, self.dim_))
             # sigma = np.sqrt(np.diag(cov))
             plot_gaussian_contours_covariance(ax, mean, cov, col, linewidth=linewidth)
diff --git a/artlib/elementary/DualVigilanceART.py b/artlib/elementary/DualVigilanceART.py
deleted file mode 100644
index cfb912a..0000000
--- a/artlib/elementary/DualVigilanceART.py
+++ /dev/null
@@ -1,287 +0,0 @@
-"""
-Brito da Silva, L. E., Elnabarawy, I., & Wunsch II, D. C. (2019).
-Dual vigilance fuzzy adaptive resonance theory.
-Neural Networks, 109, 1–5. doi:10.1016/j.neunet.2018.09.015.
-"""
-import numpy as np
-from typing import Optional, Callable, Iterable, List, Literal
-from warnings import warn
-from copy import deepcopy
-from matplotlib.axes import Axes
-from artlib.common.BaseART import BaseART
-
-
-class DualVigilanceART(BaseART):
-    # implementation of Dual Vigilance ART
-
-    def __init__(self, base_module: BaseART, rho_lower_bound: float):
-        assert isinstance(base_module, BaseART)
-        if hasattr(base_module, "base_module"):
-            warn(
-                f"{base_module.__class__.__name__} is an abstraction of the BaseART class. "
-                f"This module will only make use of the base_module {base_module.base_module.__class__.__name__}"
-            )
-        assert "rho" in base_module.params, \
-            "Dual Vigilance ART is only compatible with ART modules relying on 'rho' for vigilance."
-
-        params = {"rho_lower_bound": rho_lower_bound}
-        assert base_module.params["rho"] > params["rho_lower_bound"] >= 0
-        super().__init__(params)
-        self.base_module = base_module
-        self.rho_lower_bound = rho_lower_bound
-        self.map: dict[int, int] = dict()
-
-    def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
-
-        Parameters:
-        - X: data set
-
-        Returns:
-            base modules prepare_data
-        """
-        return self.base_module.prepare_data(X)
-
-    def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
-
-        Parameters:
-        - X: data set
-
-        Returns:
-            restored data
-        """
-        return self.base_module.restore_data(X)
-
-    def get_params(self, deep: bool = True) -> dict:
-        """
-
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
-
-        Returns:
-            Parameter names mapped to their values.
-
-        """
-        out = {
-            "rho_lower_bound": self.params["rho_lower_bound"],
-            "base_module": self.base_module
-        }
-        if deep:
-            deep_items = self.base_module.get_params().items()
-            out.update(("base_module" + "__" + k, val) for k, val in deep_items)
-
-        return out
-
-    @property
-    def n_clusters(self) -> int:
-        """
-        get the current number of clusters
-
-        Returns:
-            the number of clusters
-        """
-        return len(set(c for c in self.map.values()))
-
-    @property
-    def dim_(self):
-        return self.base_module.dim_
-
-    @dim_.setter
-    def dim_(self, new_dim):
-        self.base_module.dim_ = new_dim
-
-    @property
-    def labels_(self):
-        return self.base_module.labels_
-
-    @labels_.setter
-    def labels_(self, new_labels: np.ndarray):
-        self.base_module.labels_ = new_labels
-
-    @property
-    def W(self):
-        return self.base_module.W
-
-    @W.setter
-    def W(self, new_W: list[np.ndarray]):
-        self.base_module.W = new_W
-
-    def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
-
-        Parameters:
-        - X: data set
-
-        """
-        self.base_module.check_dimensions(X)
-
-    def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
-
-        Parameters:
-        - X: data set
-
-        """
-        self.base_module.validate_data(X)
-        self.check_dimensions(X)
-
-    def validate_params(self, params: dict):
-        """
-        validate clustering parameters
-
-        Parameters:
-        - params: dict containing parameters for the algorithm
-
-        """
-
-        assert "rho_lower_bound" in params, \
-            "Dual Vigilance ART requires a lower bound 'rho' value"
-        assert params["rho_lower_bound"] >= 0
-        assert isinstance(params["rho_lower_bound"], float)
-
-    def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
-        M = cache["match_criterion"]
-        if method == "MT+":
-            self.base_module.params["rho"] = M+epsilon
-            return True
-        elif method == "MT-":
-            self.base_module.params["rho"] = M - epsilon
-            return True
-        elif method == "MT0":
-            self.base_module.params["rho"] = M
-            return True
-        elif method == "MT1":
-            self.base_module.params["rho"] = np.inf
-            return False
-        elif method == "MT~":
-            return True
-        else:
-            raise ValueError(f"Invalid Match Tracking Method: {method}")
-
-    def _set_params(self, new_params):
-        self.base_module.params = new_params
-
-    def _deep_copy_params(self) -> dict:
-        return deepcopy(self.base_module.params)
-
-    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None,match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0) -> int:
-        """
-        fit the model to a single sample
-
-        Parameters:
-        - x: data sample
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-
-        Returns:
-            cluster label of the input sample
-
-        """
-        base_params = self._deep_copy_params()
-        mt_operator = self._match_tracking_operator(match_reset_method)
-        self.sample_counter_ += 1
-        if len(self.base_module.W) == 0:
-            new_w = self.base_module.new_weight(x, self.base_module.params)
-            self.base_module.add_weight(new_w)
-            self.map[0] = 0
-            return 0
-        else:
-            T_values, T_cache = zip(
-                *[
-                    self.base_module.category_choice(x, w, params=self.base_module.params)
-                    for w in self.base_module.W
-                ]
-            )
-            T = np.array(T_values)
-            while any(T > 0):
-                c_ = int(np.nanargmax(T))
-                w = self.base_module.W[c_]
-                cache = T_cache[c_]
-                m1, cache = self.base_module.match_criterion_bin(x, w, params=self.base_module.params, cache=cache, op=mt_operator)
-                no_match_reset = (
-                    match_reset_func is None or
-                    match_reset_func(x, w, self.map[c_], params=self.base_module.params, cache=cache)
-                )
-
-                if no_match_reset:
-                    if m1:
-                        new_w = self.base_module.update(x, w, self.base_module.params, cache=cache)
-                        self.base_module.set_weight(c_, new_w)
-                        self._set_params(base_params)
-                        return self.map[c_]
-                    else:
-                        lb_params = dict(self.base_module.params, **{"rho": self.rho_lower_bound})
-                        m2, _ = self.base_module.match_criterion_bin(x, w, params=lb_params, cache=cache, op=mt_operator)
-                        if m2:
-                            c_new = len(self.base_module.W)
-                            w_new = self.base_module.new_weight(x, self.base_module.params)
-                            self.base_module.add_weight(w_new)
-                            self.map[c_new] = self.map[c_]
-                            self._set_params(base_params)
-                            return self.map[c_new]
-                else:
-                    keep_searching = self._match_tracking(cache, epsilon, self.params, match_reset_method)
-                    if not keep_searching:
-                        T[:] = np.nan
-                T[c_] = np.nan
-
-            c_new = len(self.base_module.W)
-            w_new = self.base_module.new_weight(x, self.base_module.params)
-            self.base_module.add_weight(w_new)
-            self.map[c_new] = max(self.map.values()) + 1
-            self._set_params(base_params)
-            return self.map[c_new]
-
-    def step_pred(self, x) -> int:
-        """
-        predict the label for a single sample
-
-        Parameters:
-        - x: data sample
-
-        Returns:
-            cluster label of the input sample
-
-        """
-        assert len(self.base_module.W) >= 0, "ART module is not fit."
-        T, _ = zip(
-            *[
-                self.base_module.category_choice(x, w, params=self.base_module.params)
-                for w in self.base_module.W
-            ]
-        )
-        c_ = int(np.argmax(T))
-        return self.map[c_]
-
-    def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return self.base_module.get_cluster_centers()
-
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        function for visualizing the bounds of each cluster
-
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
-
-        """
-        colors_base = []
-        for k_a in range(self.base_module.n_clusters):
-            colors_base.append(colors[self.map[k_a]])
-
-        try:
-            self.base_module.plot_cluster_bounds(ax=ax, colors=colors_base, linewidth=linewidth)
-        except NotImplementedError:
-            warn(f"{self.base_module.__class__.__name__} does not support plotting cluster bounds.")
diff --git a/artlib/elementary/EllipsoidART.py b/artlib/elementary/EllipsoidART.py
index fd0ccd2..cfcc96b 100644
--- a/artlib/elementary/EllipsoidART.py
+++ b/artlib/elementary/EllipsoidART.py
@@ -1,30 +1,59 @@
-"""
-Anagnostopoulos, G. C., & Georgiopoulos, M. (2001a).
-Ellipsoid ART and ARTMAP for incremental clustering and classification.
-In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
-(pp. 1221–1226). volume 2. doi:10.1109/IJCNN.2001.939535.
-
-Anagnostopoulos, G. C., & Georgiopoulos, M. (2001b).
-Ellipsoid ART and ARTMAP for incremental unsupervised and supervised learning.
-In Aerospace/Defense Sensing, Simulation, and Controls (pp. 293– 304).
-International Society for Optics and Photonics. doi:10.1117/12.421180.
-"""
+"""Ellipsoid ART :cite:`anagnostopoulos2001a`, :cite:`anagnostopoulos2001b`."""
+# Anagnostopoulos, G. C., & Georgiopoulos, M. (2001a).
+# Ellipsoid ART and ARTMAP for incremental clustering and classification.
+# In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+# (pp. 1221–1226). volume 2. doi:10.1109/IJCNN.2001.939535.
+
+# Anagnostopoulos, G. C., & Georgiopoulos, M. (2001b).
+# Ellipsoid ART and ARTMAP for incremental unsupervised and supervised learning.
+# In Aerospace/Defense Sensing, Simulation, and Controls (pp. 293– 304).
+# International Society for Optics and Photonics. doi:10.1117/12.421180.
+
 import numpy as np
-from typing import Optional, Iterable, List
+from typing import Optional, List, Tuple, Dict
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
-from artlib.common.utils import l2norm2
+from artlib.common.utils import l2norm2, IndexableOrKeyable
+
 
 class EllipsoidART(BaseART):
-    # implementation of EllipsoidART
+    """Ellipsoid ART for Clustering.
+
+    This module implements Ellipsoid ART as first published in:
+    :cite:`anagnostopoulos2001a`, :cite:`anagnostopoulos2001b`.
+
+    .. # Anagnostopoulos, G. C., & Georgiopoulos, M. (2001a).
+    .. # Ellipsoid ART and ARTMAP for incremental clustering and classification.
+    .. # In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+    .. # (pp. 1221–1226). volume 2. doi:10.1109/IJCNN.2001.939535.
+
+    .. # Anagnostopoulos, G. C., & Georgiopoulos, M. (2001b).
+    .. # Ellipsoid ART and ARTMAP for incremental unsupervised and supervised learning.
+    .. # In Aerospace/Defense Sensing, Simulation, and Controls (pp. 293– 304).
+    .. # International Society for Optics and Photonics. doi:10.1117/12.421180.
+
+    Ellipsoid ART clusters data in Hyper-ellipsoids. It is highly sensitive to sample
+    presentation order as the second sample will determine the orientation of the
+    principal axes.
+
+    """
+
     def __init__(self, rho: float, alpha: float, beta: float, mu: float, r_hat: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - alpha: choice parameter
-        - beta: learning rate
-        - mu: ratio between major and minor axis
-        - r_hat: radius bias parameter
+        """Initialize the Ellipsoid ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter in the range [0, 1].
+        alpha : float
+            Choice parameter, recommended value is 1e-7.
+        beta : float
+            Learning parameter in the range [0, 1]. A value of 1 is recommended for
+            fast learning.
+        mu : float
+            Ratio between major and minor axes.
+        r_hat : float
+            Radius bias parameter.
 
         """
         params = {
@@ -38,11 +67,12 @@ def __init__(self, rho: float, alpha: float, beta: float, mu: float, r_hat: floa
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
@@ -50,10 +80,10 @@ def validate_params(params: dict):
         assert "beta" in params
         assert "mu" in params
         assert "r_hat" in params
-        assert 1.0 >= params["rho"] >= 0.
-        assert 1.0 >= params["alpha"] >= 0.
-        assert 1.0 >= params["beta"] >= 0.
-        assert 1.0 >= params["mu"] > 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert 1.0 >= params["alpha"] >= 0.0
+        assert 1.0 >= params["beta"] >= 0.0
+        assert 1.0 >= params["mu"] > 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["alpha"], float)
         assert isinstance(params["beta"], float)
@@ -61,53 +91,101 @@ def validate_params(params: dict):
         assert isinstance(params["r_hat"], float)
 
     @staticmethod
-    def category_distance(i: np.ndarray, centroid: np.ndarray, major_axis: np.ndarray, params):
-        ic_dist = (i - centroid)
+    def category_distance(
+        i: np.ndarray,
+        centroid: np.ndarray,
+        major_axis: np.ndarray,
+        params: dict,
+    ) -> float:
+        """Calculate the distance between a sample and the cluster centroid.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        centroid : np.ndarray
+            Centroid of the cluster.
+        major_axis : np.ndarray
+            Major axis of the cluster.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Distance between the sample and the cluster centroid.
+
+        """
+        ic_dist = i - centroid
 
         if major_axis.any():
-            return (1. / params["mu"]) * np.sqrt(
-                l2norm2(ic_dist) - (1 - params["mu"] * params["mu"]) * (np.matmul(major_axis, ic_dist) ** 2)
+            return (1.0 / params["mu"]) * np.sqrt(
+                l2norm2(ic_dist)
+                - (1 - params["mu"] * params["mu"])
+                * (np.matmul(major_axis, ic_dist) ** 2)
             )
         else:
             return np.sqrt(l2norm2(ic_dist))
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
-        centroid = w[:self.dim_]
-        major_axis = w[self.dim_:-1]
+        centroid = w[: self.dim_]
+        major_axis = w[self.dim_ : -1]
         radius = w[-1]
 
         dist = self.category_distance(i, centroid, major_axis, params)
 
-        cache = {
-            "dist": dist
-        }
-        return (params["r_hat"] - radius - max(radius, dist)) / (params["r_hat"] - 2*radius + params["alpha"]), cache
-
-
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+        cache = {"dist": dist}
+        return (params["r_hat"] - radius - max(radius, dist)) / (
+            params["r_hat"] - 2 * radius + params["alpha"]
+        ), cache
+
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         radius = w[-1]
@@ -115,87 +193,119 @@ def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Opt
             raise ValueError("No cache provided")
         dist = cache["dist"]
 
-        return 1 - (radius + max(radius, dist))/params["r_hat"], cache
-
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+        return 1 - (radius + max(radius, dist)) / params["r_hat"], cache
+
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
-        centroid = w[:self.dim_]
-        major_axis = w[self.dim_:-1]
+        centroid = w[: self.dim_]
+        major_axis = w[self.dim_ : -1]
         radius = w[-1]
 
         if cache is None:
             raise ValueError("No cache provided")
         dist = cache["dist"]
 
-        radius_new = radius + (params["beta"]/2)*(max(radius, dist) - radius)
-        centroid_new = centroid + (params["beta"]/2)*(i-centroid)*(1-(min(radius, dist)/dist))
+        radius_new = radius + (params["beta"] / 2) * (max(radius, dist) - radius)
+        centroid_new = centroid + (params["beta"] / 2) * (i - centroid) * (
+            1 - (min(radius, dist) / dist)
+        )
 
-        if not radius == 0.:
-            major_axis_new = (i-centroid_new)/np.sqrt(l2norm2((i-centroid_new)))
+        if not radius == 0.0:
+            major_axis_new = (i - centroid_new) / np.sqrt(l2norm2((i - centroid_new)))
         else:
             major_axis_new = major_axis
 
         return np.concatenate([centroid_new, major_axis_new, [radius_new]])
 
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
-        return np.concatenate([i, np.zeros_like(i), [0.]])
+        return np.concatenate([i, np.zeros_like(i), [0.0]])
+
+    def get_2d_ellipsoids(self) -> List[Tuple[np.ndarray, float, float, float]]:
+        """Get the 2D ellipsoids for visualization.
 
-    def get_2d_ellipsoids(self) -> list[tuple]:
+        Returns
+        -------
+        list of tuple
+            Each tuple contains the centroid, width, height, and angle of an ellipsoid.
+
+        """
         ellipsoids = []
         for w in self.W:
             centroid = w[:2]
-            major_axis = w[self.dim_:-1]
+            major_axis = w[self.dim_ : -1]
             radius = w[-1]
 
             angle = np.rad2deg(np.arctan2(major_axis[1], major_axis[0]))
-            height = radius*2
-            width = self.params["mu"]*height
+            height = radius * 2
+            width = self.params["mu"] * height
 
             ellipsoids.append((centroid, width, height, angle))
 
         return ellipsoids
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return [w[:self.dim_] for w in self.W]
+        """Get the centers of each cluster, used for regression.
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        """
+        return [w[: self.dim_] for w in self.W]
+
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the bounds of each cluster.
+
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         from matplotlib.patches import Ellipse
@@ -209,12 +319,6 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
                 angle=angle,
                 linewidth=linewidth,
                 edgecolor=col,
-                facecolor='none'
+                facecolor="none",
             )
             ax.add_patch(ellip)
-
-
-
-
-
-
diff --git a/artlib/elementary/FuzzyART.py b/artlib/elementary/FuzzyART.py
index 7ecef9b..60faaa8 100644
--- a/artlib/elementary/FuzzyART.py
+++ b/artlib/elementary/FuzzyART.py
@@ -1,25 +1,39 @@
-"""
-Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991c).
-Fuzzy ART: Fast stable learning and categorization of analog patterns by an adaptive resonance system.
-Neural Networks, 4, 759 – 771. doi:10.1016/0893-6080(91)90056-B.
-"""
+"""Fuzzy ART :cite:`carpenter1991fuzzy`."""
+# Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991c).
+# Fuzzy ART: Fast stable learning and categorization of analog patterns by an
+# adaptive resonance system.
+# Neural Networks, 4, 759 – 771. doi:10.1016/0893-6080(91)90056-B.
+
 import numpy as np
-from typing import Optional, Iterable, List
+from typing import Optional, Iterable, List, Tuple, Dict
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
-from artlib.common.utils import normalize, compliment_code, l1norm, fuzzy_and, de_compliment_code
-
-
-def get_bounding_box(w: np.ndarray, n: Optional[int] = None) -> tuple[list[int], list[int]]:
-    """
-    extract the bounding boxes from a FuzzyART weight
-
-    Parameters:
-    - w: a fuzzy ART weight
-    - n: dimensions of the bounding box
+from artlib.common.utils import (
+    normalize,
+    compliment_code,
+    l1norm,
+    fuzzy_and,
+    de_compliment_code,
+)
+
+
+def get_bounding_box(
+    w: np.ndarray, n: Optional[int] = None
+) -> tuple[list[int], list[int]]:
+    """Extract the bounding boxes from a FuzzyART weight.
+
+    Parameters
+    ----------
+    w : np.ndarray
+        A fuzzy ART weight.
+    n : int, optional
+        Dimensions of the bounding box.
+
+    Returns
+    -------
+    tuple
+        A tuple containing the reference point and lengths of each edge.
 
-    Returns:
-        reference_point, lengths of each edge
     """
     n_ = int(len(w) / 2)
     if n is None:
@@ -31,22 +45,41 @@ def get_bounding_box(w: np.ndarray, n: Optional[int] = None) -> tuple[list[int],
 
     for i in range(n):
         a_min = w[i]
-        a_max = 1-w[i+n]
+        a_max = 1 - w[i + n]
 
         ref_point.append(a_min)
-        widths.append(a_max-a_min)
+        widths.append(a_max - a_min)
 
     return ref_point, widths
 
 
 class FuzzyART(BaseART):
-    # implementation of FuzzyART
+    """Fuzzy ART for Clustering.
+
+    This module implements Fuzzy ART as first published in:
+    :cite:`carpenter1991fuzzy`.
+
+    .. # Carpenter, G. A., Grossberg, S., & Rosen, D. B. (1991c).
+    .. # Fuzzy ART: Fast stable learning and categorization of analog patterns by an
+    .. # adaptive resonance system.
+    .. # Neural Networks, 4, 759 – 771. doi:10.1016/0893-6080(91)90056-B.
+
+    Fuzzy ART is a hyper-box based clustering method that is exceptionally fast and
+    explainable.
+
+    """
+
     def __init__(self, rho: float, alpha: float, beta: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - alpha: choice parameter
-        - beta: learning rate
+        """Initialize the Fuzzy ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
+        alpha : float
+            Choice parameter.
+        beta : float
+            Learning rate.
 
         """
         params = {
@@ -57,123 +90,170 @@ def __init__(self, rho: float, alpha: float, beta: float):
         super().__init__(params)
 
     def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+        """Prepare data for clustering.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset.
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        np.ndarray
+            Normalized and compliment coded data.
 
-        Returns:
-            normalized and compliment coded data
         """
         normalized, self.d_max_, self.d_min_ = normalize(X, self.d_max_, self.d_min_)
         cc_data = compliment_code(normalized)
         return cc_data
 
     def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+        """Restore data to its state prior to preparation.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset.
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-        Returns:
-            restored data
         """
         out = de_compliment_code(X)
         return super(FuzzyART, self).restore_data(out)
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
         assert "alpha" in params
         assert "beta" in params
-        assert 1.0 >= params["rho"] >= 0.
-        assert params["alpha"] >= 0.
-        assert 1.0 >= params["beta"] > 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert params["alpha"] >= 0.0
+        assert 1.0 >= params["beta"] > 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["alpha"], float)
         assert isinstance(params["beta"], float)
 
     def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
+        """Check that the data has the correct dimensions.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset.
 
         """
         if not hasattr(self, "dim_"):
             self.dim_ = X.shape[1]
-            self.dim_original = int(self.dim_//2)
+            self.dim_original = int(self.dim_ // 2)
         else:
             assert X.shape[1] == self.dim_
 
     def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the data prior to clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Dataset.
 
         """
         assert X.shape[1] % 2 == 0, "Data has not been compliment coded"
         assert np.all(X >= 0), "Data has not been normalized"
         assert np.all(X <= 1.0), "Data has not been normalized"
-        assert np.all(abs(np.sum(X, axis=1) - float(X.shape[1]/2)) <= 0.01), "Data has not been compliment coded"
+        assert np.all(
+            abs(np.sum(X, axis=1) - float(X.shape[1] / 2)) <= 0.01
+        ), "Data has not been compliment coded"
         self.check_dimensions(X)
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
         return l1norm(fuzzy_and(i, w)) / (params["alpha"] + l1norm(w)), None
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         return l1norm(fuzzy_and(i, w)) / self.dim_original, cache
 
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         b = params["beta"]
@@ -182,59 +262,92 @@ def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dic
         return b * fuzzy_and(i, w) + (1 - b) * w
 
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
         return i
 
-    def get_bounding_boxes(self, n: Optional[int] = None):
+    def get_bounding_boxes(
+        self, n: Optional[int] = None
+    ) -> List[tuple[list[int], list[int]]]:
+        """Get the bounding boxes for each cluster.
+
+        Parameters
+        ----------
+        n : int, optional
+            Dimensions of the bounding box.
+
+        Returns
+        -------
+        list
+            List of bounding boxes.
+
+        """
         return list(map(lambda w: get_bounding_box(w, n=n), self.W))
 
     def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        centers = []
-        for w in self.W:
-            ref_points, widths = get_bounding_box(w,None)
-            centers.append(np.array(ref_points)+0.5*np.array(widths))
-        return centers
+        return [self.restore_data(w.reshape((1, -1))).reshape((-1,)) for w in self.W]
 
     def shrink_clusters(self, shrink_ratio: float = 0.1):
+        """Shrink the clusters by adjusting the bounding box.
+
+        Parameters
+        ----------
+        shrink_ratio : float, optional
+            The ratio by which to shrink the clusters, by default 0.1.
+
+        Returns
+        -------
+        FuzzyART
+            Self after shrinking the clusters.
+
+        """
         new_W = []
-        dim = len(self.W[0])//2
+        dim = len(self.W[0]) // 2
         for w in self.W:
             new_w = np.copy(w)
-            widths = (1-w[dim:]) - w[:dim]
-            new_w[:dim] += widths*shrink_ratio
-            new_w[dim:] += widths*shrink_ratio
+            widths = (1 - w[dim:]) - w[:dim]
+            new_w[:dim] += widths * shrink_ratio
+            new_w[dim:] += widths * shrink_ratio
             new_W.append(new_w)
         self.W = new_W
         return self
 
-
     def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         from matplotlib.patches import Rectangle
+
         bboxes = self.get_bounding_boxes(n=2)
         for bbox, col in zip(bboxes, colors):
             rect = Rectangle(
@@ -243,6 +356,6 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
                 bbox[1][1],
                 linewidth=linewidth,
                 edgecolor=col,
-                facecolor='none'
+                facecolor="none",
             )
             ax.add_patch(rect)
diff --git a/artlib/elementary/GaussianART.py b/artlib/elementary/GaussianART.py
index ccf2b9f..2c77ee1 100644
--- a/artlib/elementary/GaussianART.py
+++ b/artlib/elementary/GaussianART.py
@@ -1,99 +1,134 @@
-"""
-Williamson, J. R. (1996).
-Gaussian ARTMAP: A Neural Network for Fast Incremental Learning of Noisy Multidimensional Maps.
-Neural Networks, 9, 881 – 897. doi:10.1016/0893-6080(95)00115-8.
-"""
+"""Gaussian ART :cite:`williamson1996gaussian`."""
+# Williamson, J. R. (1996).
+# Gaussian ARTMAP: A Neural Network for Fast Incremental Learning of Noisy
+# Multidimensional Maps.
+# Neural Networks, 9, 881 – 897. doi:10.1016/0893-6080(95)00115-8.
 
 import numpy as np
-from decimal import Decimal
-from typing import Optional, Iterable, List
+from typing import Optional, Iterable, List, Tuple, Dict
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
 from artlib.common.visualization import plot_gaussian_contours_fading
 
 
 class GaussianART(BaseART):
-    # implementation of GaussianART
+    """Gaussian ART for Clustering.
+
+    This module implements Gaussian ART as first published in:
+    :cite:`williamson1996gaussian`.
+
+    .. # Williamson, J. R. (1996).
+    .. # Gaussian ARTMAP: A Neural Network for Fast Incremental Learning of Noisy
+    .. # Multidimensional Maps.
+    .. # Neural Networks, 9, 881 – 897. doi:10.1016/0893-6080(95)00115-8.
+
+    Guassian ART clusters data in Gaussian Distributions (Hyper-ellipsoids) and is
+    similar to :class:`~artlib.elementary.BayesianART.BayesianART` but differs in that
+    the hyper-ellipsoid always have their principal axes square to the coordinate
+    frame. It is also faster than :class:`~artlib.elementary.BayesianART.BayesianART`.
+
+    """
+
     def __init__(self, rho: float, sigma_init: np.ndarray, alpha: float = 1e-10):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - sigma_init: initial estimate of the diagonal std
-        - alpha: used to prevent division by zero errors
+        """Initialize the Gaussian ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
+        sigma_init : np.ndarray
+            Initial estimate of the diagonal standard deviations.
+        alpha : float, optional
+            Small parameter to prevent division by zero errors, by default 1e-10.
 
         """
-        params = {
-            "rho": rho,
-            "sigma_init": sigma_init,
-            "alpha": alpha
-        }
+        params = {"rho": rho, "sigma_init": sigma_init, "alpha": alpha}
         super().__init__(params)
 
-
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
         assert "sigma_init" in params
         assert "alpha" in params
-        assert 1.0 >= params["rho"] >= 0.
-        assert params["alpha"] > 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert params["alpha"] > 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["sigma_init"], np.ndarray)
 
-
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
-
-        """
-        mean = w[:self.dim_]
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
+
+        """
+        mean = w[: self.dim_]
         # sigma = w[self.dim_:2*self.dim]
-        inv_sig = w[2*self.dim_:3*self.dim_]
+        inv_sig = w[2 * self.dim_ : 3 * self.dim_]
         sqrt_det_sig = w[-2]
         n = w[-1]
 
-        dist = mean-i
+        dist = mean - i
         exp_dist_sig_dist = np.exp(-0.5 * np.dot(dist, np.multiply(inv_sig, dist)))
 
-        cache = {
-            "exp_dist_sig_dist": exp_dist_sig_dist
-        }
+        cache = {"exp_dist_sig_dist": exp_dist_sig_dist}
         # ignore the (2*pi)^d term as that is constant
-        p_i_cj = exp_dist_sig_dist/(params["alpha"]+sqrt_det_sig)
-        p_cj = n/np.sum(w_[-1] for w_ in self.W)
+        p_i_cj = exp_dist_sig_dist / (params["alpha"] + sqrt_det_sig)
+        p_cj = n / np.sum(w_[-1] for w_ in self.W)
 
-        activation = p_i_cj*p_cj
+        activation = p_i_cj * p_cj
 
         return activation, cache
 
-
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         if cache is None:
@@ -101,29 +136,42 @@ def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Opt
         exp_dist_sig_dist = cache["exp_dist_sig_dist"]
         return exp_dist_sig_dist, cache
 
-
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
-
-        """
-        mean = w[:self.dim_]
-        sigma = w[self.dim_:2*self.dim_]
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
+
+        """
+        mean = w[: self.dim_]
+        sigma = w[self.dim_ : 2 * self.dim_]
         n = w[-1]
 
-        n_new = n+1
-        mean_new = (1-(1/n_new))*mean + (1/n_new)*i
-        sigma_new = np.sqrt((1-(1/n_new))*np.multiply(sigma, sigma) + (1/n_new)*((mean_new - i)**2))
+        n_new = n + 1
+        mean_new = (1 - (1 / n_new)) * mean + (1 / n_new) * i
+        sigma_new = np.sqrt(
+            (1 - (1 / n_new)) * np.multiply(sigma, sigma)
+            + (1 / n_new) * ((mean_new - i) ** 2)
+        )
 
         sigma2 = np.multiply(sigma_new, sigma_new)
         inv_sig = 1 / sigma2
@@ -131,45 +179,54 @@ def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dic
 
         return np.concatenate([mean_new, sigma_new, inv_sig, [det_sig], [n_new]])
 
-
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
         sigma2 = np.multiply(params["sigma_init"], params["sigma_init"])
         inv_sig_init = 1 / sigma2
         det_sig_init = np.sqrt(np.prod(sigma2))
-        return np.concatenate([i, params["sigma_init"], inv_sig_init, [det_sig_init], [1.]])
+        return np.concatenate(
+            [i, params["sigma_init"], inv_sig_init, [det_sig_init], [1.0]]
+        )
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        return [w[:self.dim_] for w in self.W]
+        """Get the centers of each cluster, used for regression.
 
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
         """
-        undefined function for visualizing the bounds of each cluster
+        return [w[: self.dim_] for w in self.W]
+
+    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         for w, col in zip(self.W, colors):
-            mean = w[:self.dim_]
-            sigma = w[self.dim_:-1]
+            mean = w[: self.dim_]
+            sigma = w[self.dim_ : -1]
             plot_gaussian_contours_fading(ax, mean, sigma, col, linewidth=linewidth)
diff --git a/artlib/elementary/HypersphereART.py b/artlib/elementary/HypersphereART.py
index 5716c38..3f84595 100644
--- a/artlib/elementary/HypersphereART.py
+++ b/artlib/elementary/HypersphereART.py
@@ -1,24 +1,45 @@
-"""
-Anagnostopoulos, G. C., & Georgiopulos, M. (2000).
-Hypersphere ART and ARTMAP for unsupervised and supervised, incremental learning.
-In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
-(pp. 59–64). volume 6. doi:10.1109/IJCNN.2000.859373.
-"""
+"""Hyperpshere ART :cite:`anagnostopoulos2000hypersphere`."""
+# Anagnostopoulos, G. C., & Georgiopulos, M. (2000).
+# Hypersphere ART and ARTMAP for unsupervised and supervised, incremental learning.
+# In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+# (pp. 59–64). volume 6. doi:10.1109/IJCNN.2000.859373.
+
 import numpy as np
 from typing import Optional, Iterable, List
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
 from artlib.common.utils import l2norm2
 
+
 class HypersphereART(BaseART):
-    # implementation of HypersphereART
+    """Hypersphere ART for Clustering.
+
+    This module implements Ellipsoid ART as first published in:
+    :cite:`anagnostopoulos2000hypersphere`.
+
+    .. # Anagnostopoulos, G. C., & Georgiopulos, M. (2000).
+    .. # Hypersphere ART and ARTMAP for unsupervised and supervised, incremental
+    .. # learning.
+    .. # In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+    .. # (pp. 59–64). volume 6. doi:10.1109/IJCNN.2000.859373.
+
+    Hyperpshere ART clusters data in Hyper-spheres similar to k-means with a dynamic k.
+
+    """
+
     def __init__(self, rho: float, alpha: float, beta: float, r_hat: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - alpha: choice parameter
-        - beta: learning rate
-        - r_hat: maximum possible category radius
+        """Initialize the Hypersphere ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
+        alpha : float
+            Choice parameter.
+        beta : float
+            Learning rate.
+        r_hat : float
+            Maximum possible category radius.
 
         """
         params = {
@@ -31,41 +52,71 @@ def __init__(self, rho: float, alpha: float, beta: float, r_hat: float):
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
         assert "alpha" in params
         assert "beta" in params
         assert "r_hat" in params
-        assert 1.0 >= params["rho"] >= 0.
-        assert params["alpha"] >= 0.
-        assert 1.0 >= params["beta"] >= 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert params["alpha"] >= 0.0
+        assert 1.0 >= params["beta"] >= 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["alpha"], float)
         assert isinstance(params["beta"], float)
         assert isinstance(params["r_hat"], float)
 
     @staticmethod
-    def category_distance(i: np.ndarray, centroid: np.ndarray, radius: float, params) -> float:
-        return np.sqrt(l2norm2(i-centroid))
+    def category_distance(
+        i: np.ndarray, centroid: np.ndarray, radius: float, params
+    ) -> float:
+        """Compute the category distance between a data sample and a centroid.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        centroid : np.ndarray
+            Cluster centroid.
+        radius : float
+            Cluster radius.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Category distance.
 
-
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
         """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
+        return np.sqrt(l2norm2(i - centroid))
+
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
         centroid = w[:-1]
@@ -78,21 +129,36 @@ def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[f
             "max_radius": max_radius,
             "i_radius": i_radius,
         }
-        return (params["r_hat"] - max_radius)/(params["r_hat"] - radius + params["alpha"]), cache
-
-
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+        return (params["r_hat"] - max_radius) / (
+            params["r_hat"] - radius + params["alpha"]
+        ), cache
+
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> tuple[float, Optional[dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         radius = w[-1]
@@ -100,22 +166,32 @@ def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Opt
             raise ValueError("No cache provided")
         max_radius = cache["max_radius"]
 
-        return 1 - (max(radius, max_radius)/params["r_hat"]), cache
-
-
-
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+        return 1 - (max(radius, max_radius) / params["r_hat"]), cache
+
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
         centroid = w[:-1]
@@ -125,44 +201,53 @@ def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dic
         max_radius = cache["max_radius"]
         i_radius = cache["i_radius"]
 
-        radius_new = radius + (params["beta"]/2)*(max_radius-radius)
-        centroid_new = centroid + (params["beta"]/2)*(i-centroid)*(1-(min(radius, i_radius)/i_radius))
+        radius_new = radius + (params["beta"] / 2) * (max_radius - radius)
+        centroid_new = centroid + (params["beta"] / 2) * (i - centroid) * (
+            1 - (min(radius, i_radius) / i_radius)
+        )
 
         return np.concatenate([centroid_new, [radius_new]])
 
-
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
-        return np.concatenate([i, [0.]])
+        return np.concatenate([i, [0.0]])
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
         return [w[:-1] for w in self.W]
 
-
     def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         from matplotlib.patches import Circle
@@ -175,12 +260,6 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
                 radius,
                 linewidth=linewidth,
                 edgecolor=col,
-                facecolor='none'
+                facecolor="none",
             )
             ax.add_patch(circ)
-
-
-
-
-
-
diff --git a/artlib/elementary/QuadraticNeuronART.py b/artlib/elementary/QuadraticNeuronART.py
index 04b42ca..2ae001f 100644
--- a/artlib/elementary/QuadraticNeuronART.py
+++ b/artlib/elementary/QuadraticNeuronART.py
@@ -1,15 +1,14 @@
-"""
-Su, M.-C., & Liu, T.-K. (2001).
-Application of neural networks using quadratic junctions in cluster analysis.
-Neurocomputing, 37, 165 – 175. doi:10.1016/S0925-2312(00)00343-X.
+"""Quadratic Neuron ART :cite:`su2001application`, :cite:`su2005new`."""
+# Su, M.-C., & Liu, T.-K. (2001).
+# Application of neural networks using quadratic junctions in cluster analysis.
+# Neurocomputing, 37, 165 – 175. doi:10.1016/S0925-2312(00)00343-X.
 
-Su, M.-C., & Liu, Y.-C. (2005).
-A new approach to clustering data with arbitrary shapes.
-Pattern Recognition, 38, 1887 – 1901. doi:10.1016/j.patcog.2005.04.010.
-"""
+# Su, M.-C., & Liu, Y.-C. (2005).
+# A new approach to clustering data with arbitrary shapes.
+# Pattern Recognition, 38, 1887 – 1901. doi:10.1016/j.patcog.2005.04.010.
 
 import numpy as np
-from typing import Optional, Iterable, List
+from typing import Optional, Iterable, List, Tuple, Dict
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
 from artlib.common.utils import l2norm2
@@ -17,15 +16,41 @@
 
 
 class QuadraticNeuronART(BaseART):
-    # implementation of QuadraticNeuronART
-    def __init__(self, rho: float, s_init: float, lr_b: float, lr_w: float, lr_s: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-        - s_init: initial linear activation parameter
-        - lr_b: learning rate for cluster mean
-        - lr_w: learning rate for cluster weights
-        - lr_s: learning rate for cluster activation parameter
+    """Quadratic Neuron ART for Clustering.
+
+    This module implements Quadratic Neuron ART as first published in:
+    :cite:`su2001application`, :cite:`su2005new`.
+
+    .. # Su, M.-C., & Liu, T.-K. (2001).
+    .. # Application of neural networks using quadratic junctions in cluster analysis.
+    .. # Neurocomputing, 37, 165 – 175. doi:10.1016/S0925-2312(00)00343-X.
+
+    .. # Su, M.-C., & Liu, Y.-C. (2005).
+    .. # A new approach to clustering data with arbitrary shapes.
+    .. # Pattern Recognition, 38, 1887 – 1901. doi:10.1016/j.patcog.2005.04.010.
+
+    Quadratic Neuron ART clusters data in Hyper-ellipsoid by utilizing a quadratic
+    neural network for activation and resonance.
+
+    """
+
+    def __init__(
+        self, rho: float, s_init: float, lr_b: float, lr_w: float, lr_s: float
+    ):
+        """Initialize the Quadratic Neuron ART model.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
+        s_init : float
+            Initial quadratic term.
+        lr_b : float
+            Learning rate for cluster mean (bias).
+        lr_w : float
+            Learning rate for cluster weights.
+        lr_s : float
+            Learning rate for the quadratic term.
 
         """
         params = {
@@ -39,11 +64,12 @@ def __init__(self, rho: float, s_init: float, lr_b: float, lr_w: float, lr_s: fl
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
         assert "rho" in params
@@ -51,27 +77,36 @@ def validate_params(params: dict):
         assert "lr_b" in params
         assert "lr_w" in params
         assert "lr_s" in params
-        assert 1.0 >= params["rho"] >= 0.
-        assert 1.0 >= params["lr_b"] > 0.
-        assert 1.0 >= params["lr_w"] >= 0.
-        assert 1.0 >= params["lr_s"] >= 0.
+        assert 1.0 >= params["rho"] >= 0.0
+        assert 1.0 >= params["lr_b"] > 0.0
+        assert 1.0 >= params["lr_w"] >= 0.0
+        assert 1.0 >= params["lr_s"] >= 0.0
         assert isinstance(params["rho"], float)
         assert isinstance(params["s_init"], float)
         assert isinstance(params["lr_b"], float)
         assert isinstance(params["lr_w"], float)
         assert isinstance(params["lr_s"], float)
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
 
         """
         dim2 = self.dim_ * self.dim_
@@ -79,8 +114,8 @@ def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[f
         b = w[dim2:-1]
         s = w[-1]
         z = np.matmul(w_, i)
-        l2norm2_z_b = l2norm2(z-b)
-        activation = np.exp(-s*s*l2norm2_z_b)
+        l2norm2_z_b = l2norm2(z - b)
+        activation = np.exp(-s * s * l2norm2_z_b)
 
         cache = {
             "activation": activation,
@@ -88,42 +123,69 @@ def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[f
             "w": w_,
             "b": b,
             "s": s,
-            "z": z
+            "z": z,
         }
         return activation, cache
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
 
         """
         if cache is None:
             raise ValueError("No cache provided")
         return cache["activation"], cache
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight, cache used for later processing.
 
         """
+        assert cache is not None
         s = cache["s"]
         w_ = cache["w"]
         b = cache["b"]
@@ -131,48 +193,58 @@ def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dic
         T = cache["activation"]
         l2norm2_z_b = cache["l2norm2_z_b"]
 
-        sst2 = 2*s*s*T
+        sst2 = 2 * s * s * T
 
-        b_new = b + params["lr_b"]*(sst2*(z-b))
-        w_new = w_ + params["lr_w"]*(-sst2*((z-b).reshape((-1, 1))*i.reshape((1, -1))))
-        s_new = s + params["lr_s"]*(-2*s*T*l2norm2_z_b)
+        b_new = b + params["lr_b"] * (sst2 * (z - b))
+        w_new = w_ + params["lr_w"] * (
+            -sst2 * ((z - b).reshape((-1, 1)) * i.reshape((1, -1)))
+        )
+        s_new = s + params["lr_s"] * (-2 * s * T * l2norm2_z_b)
 
         return np.concatenate([w_new.flatten(), b_new, [s_new]])
 
-
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
         w_new = np.identity(self.dim_)
         return np.concatenate([w_new.flatten(), i, [params["s_init"]]])
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
         """
         dim2 = self.dim_ * self.dim_
         return [w[dim2:-1] for w in self.W]
 
     def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+        """Visualize the bounds of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
 
         """
         # kinda works
@@ -182,4 +254,3 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
             b = w[dim2:-1]
             s = w[-1]
             plot_weight_matrix_as_ellipse(ax, s, w_, b, col)
-
diff --git a/artlib/elementary/__init__.py b/artlib/elementary/__init__.py
index e69de29..80afb39 100644
--- a/artlib/elementary/__init__.py
+++ b/artlib/elementary/__init__.py
@@ -0,0 +1,6 @@
+"""Elementary ART modules which are those ART modules that do not implement an
+abstraction layer on top of another module.
+
+These are the most basic clustering methods with the simplest geometries.
+
+"""
diff --git a/artlib/experimental/ConvexHullART.py b/artlib/experimental/ConvexHullART.py
deleted file mode 100644
index 84b2092..0000000
--- a/artlib/experimental/ConvexHullART.py
+++ /dev/null
@@ -1,327 +0,0 @@
-import numpy as np
-from matplotlib.axes import Axes
-from copy import deepcopy
-from typing import Optional, Iterable, List, Union
-from scipy.spatial import ConvexHull
-
-from artlib.common.BaseART import BaseART
-from artlib.experimental.merging import merge_objects
-
-
-def plot_convex_polygon(vertices: np.ndarray, ax: Axes, line_color: str = 'b', line_width: float = 1.0):
-    """
-    Plots a convex polygon given its vertices using Matplotlib.
-
-    Parameters:
-    - vertices: A list of vertices representing a convex polygon.
-    - ax: A matplotlib Axes object to plot on. If None, creates a new figure and axes.
-    - line_color: The color of the polygon lines.
-    - line_width: The width of the polygon lines.
-    """
-    vertices = np.array(vertices)
-    # Close the polygon by appending the first vertex at the end
-    vertices = np.vstack([vertices, vertices[0]])
-
-    ax.plot(vertices[:, 0], vertices[:, 1], linestyle='-', color=line_color, linewidth=line_width)
-
-
-def volume_of_simplex(vertices):
-    """
-    Calculates the n-dimensional volume of a simplex defined by its vertices.
-
-    Parameters:
-    - vertices: An (n+1) x n array representing the coordinates of the simplex vertices.
-
-    Returns:
-    - Volume of the simplex.
-    """
-    vertices = np.asarray(vertices)
-    # Subtract the first vertex from all vertices to form a matrix
-    matrix = vertices[1:] - vertices[0]
-    # Calculate the absolute value of the determinant divided by factorial(n) for volume
-    return np.abs(np.linalg.det(matrix)) / np.math.factorial(len(vertices) - 1)
-
-
-def minimum_distance(a1, a2):
-    def point_to_point_distance(P, Q):
-        """Calculate the Euclidean distance between two points P and Q."""
-        return np.linalg.norm(P - Q)
-
-    def point_to_line_segment_distance(P, Q1, Q2):
-        """Calculate the minimum distance between point P and line segment Q1-Q2."""
-        line_vec = Q2 - Q1
-        point_vec = P - Q1
-        line_len = np.dot(line_vec, line_vec)
-
-        if line_len == 0:  # Q1 and Q2 are the same point
-            return np.linalg.norm(P - Q1)
-
-        t = max(0, min(1, np.dot(point_vec, line_vec) / line_len))
-        projection = Q1 + t * line_vec
-        return np.linalg.norm(P - projection)
-
-    def line_segment_to_line_segment_distance(A1, A2, B1, B2):
-        """Calculate the minimum distance between two line segments A1-A2 and B1-B2."""
-        distances = [
-            point_to_line_segment_distance(A1, B1, B2),
-            point_to_line_segment_distance(A2, B1, B2),
-            point_to_line_segment_distance(B1, A1, A2),
-            point_to_line_segment_distance(B2, A1, A2),
-        ]
-        return min(distances)
-
-    # Determine the cases and compute the distance
-    if a1.shape[0] == 1 and a2.shape[0] == 1:
-        # Case 1: Point to point
-        return point_to_point_distance(a1[0], a2[0])
-
-    elif a1.shape[0] == 1 and a2.shape[0] == 2:
-        # Case 2: Point to line segment
-        return point_to_line_segment_distance(a1[0], a2[0], a2[1])
-
-    elif a1.shape[0] == 2 and a2.shape[0] == 1:
-        # Case 3: Line segment to point
-        return point_to_line_segment_distance(a2[0], a1[0], a1[1])
-
-    elif a1.shape[0] == 2 and a2.shape[0] == 2:
-        # Case 4: Line segment to line segment
-        return line_segment_to_line_segment_distance(a1[0], a1[1], a2[0], a2[1])
-    else:
-        raise RuntimeError("INVALID DISTANCE CALCULATION")
-
-
-
-class PseudoConvexHull:
-    def __init__(self, points: np.ndarray):
-        self.points = points
-
-    @property
-    def vertices(self):
-        return np.array([i for i in range(self.points.shape[0])])
-
-    def add_points(self, points):
-        self.points = np.vstack([self.points, points])
-
-
-HullTypes = Union[ConvexHull, PseudoConvexHull]
-
-
-def centroid_of_convex_hull(hull: HullTypes):
-    """
-    Finds the centroid of the volume of a convex hull in n-dimensional space.
-
-    Parameters:
-    - vertices: An array of shape (m, n), where m is the number of vertices and n is the dimension.
-
-    Returns:
-    - Centroid coordinates as a numpy array of length n.
-    """
-    hull_vertices = hull.points[hull.vertices]
-
-    centroid = np.zeros(hull_vertices.shape[1])
-    total_volume = 0
-
-    if isinstance(hull, PseudoConvexHull):
-        return np.mean(hull.points, axis=0)
-
-    for simplex in hull.simplices:
-        simplex_vertices = hull_vertices[simplex]
-        simplex_centroid = np.mean(simplex_vertices, axis=0)
-        simplex_volume = volume_of_simplex(simplex_vertices)
-
-        centroid += simplex_centroid * simplex_volume
-        total_volume += simplex_volume
-
-    centroid /= total_volume
-    return centroid
-
-
-class ConvexHullART(BaseART):
-    def __init__(self, rho: float, merge_rho: float):
-        """
-        Parameters:
-        - rho: vigilance parameter
-
-        """
-        params = {
-            "rho": rho,
-            "merge_rho": merge_rho
-        }
-        super().__init__(params)
-
-    @staticmethod
-    def validate_params(params: dict):
-        """
-        validate clustering parameters
-
-        Parameters:
-        - params: dict containing parameters for the algorithm
-
-        """
-        assert "rho" in params
-        assert params["rho"] >= 0.
-        assert isinstance(params["rho"], float)
-
-    def category_choice(self, i: np.ndarray, w: HullTypes, params: dict) -> tuple[float, Optional[dict]]:
-        """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
-
-        """
-        if isinstance(w, PseudoConvexHull):
-            d = minimum_distance(i.reshape((1,-1)), w.points)
-            activation = 1. - d**len(i)
-            if w.points.shape[0] == 1:
-                new_w = deepcopy(w)
-                new_w.add_points(i.reshape((1,-1)))
-            else:
-                new_points = np.vstack([w.points[w.vertices,:], i.reshape((1,-1))])
-                new_w = ConvexHull(new_points, incremental=True)
-        else:
-            new_w = ConvexHull(w.points[w.vertices,:], incremental=True)
-            new_w.add_points(i.reshape((1,-1)))
-            activation = w.area / new_w.area
-
-        cache = {"new_w": new_w, "activation": activation}
-        return activation, cache
-
-    def match_criterion(self, i: np.ndarray, w: HullTypes, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
-
-        """
-        return cache["activation"], cache
-
-
-    def update(self, i: np.ndarray, w: HullTypes, params: dict, cache: Optional[dict] = None) -> HullTypes:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
-
-        """
-        return cache["new_w"]
-
-    def new_weight(self, i: np.ndarray, params: dict) -> HullTypes:
-        """
-        generate a new cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            updated cluster weight
-
-        """
-        new_w = PseudoConvexHull(i.reshape((1,-1)))
-        return new_w
-
-    def merge_clusters(self):
-        def can_merge(w1, w2):
-            combined_points = np.vstack([w1.points[w1.vertices,:], w2.points[w2.vertices,:]])
-
-            if isinstance(w1, PseudoConvexHull) and isinstance(w2, PseudoConvexHull):
-                d = minimum_distance(w1.points, w2.points)
-                activation = 1.0 - d**w1.points.shape[1]
-            else:
-                new_w = ConvexHull(combined_points)
-                if isinstance(w1, ConvexHull):
-                    a1 = w1.area / new_w.area
-                else:
-                    a1 = np.nan
-                if isinstance(w2, ConvexHull):
-                    a2 = w2.area / new_w.area
-                else:
-                    a2 = np.nan
-                activation = np.max([a1,a2])
-
-            if activation > self.params["merge_rho"]:
-                return True
-            else:
-                return False
-
-        merges = merge_objects(self.W, can_merge)
-
-        new_W = []
-        new_sample_counter = []
-        new_labels = np.copy(self.labels_)
-        for i in range(len(merges)):
-            new_labels[np.isin(self.labels_, merges[i])] = i
-            new_sample_counter.append(sum(self.weight_sample_counter_[j] for j in merges[i]))
-            if len(merges[i]) > 1:
-                new_points = np.vstack([self.W[j].points for j in merges[i]])
-                if new_points.shape[0] > 2:
-                    new_W.append(ConvexHull(new_points, incremental=True))
-                else:
-                    new_W.append(PseudoConvexHull(new_points))
-            else:
-                new_W.append(self.W[merges[i][0]])
-        self.W = new_W
-        self.weight_sample_counter_ = new_sample_counter
-        self.labels_ = new_labels
-
-    def post_fit(self, X: np.ndarray):
-        """
-        function called after fit. Useful for cluster pruning
-
-        Parameters:
-        - X: data set
-
-        """
-        self.merge_clusters()
-
-
-
-
-    def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
-        """
-        centers = []
-        for w in self.W:
-            centers.append(centroid_of_convex_hull(w))
-        return centers
-
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
-
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
-
-        """
-        for c, w in zip(colors, self.W):
-            if isinstance(w, ConvexHull):
-                vertices = w.points[w.vertices,:2]
-            else:
-                vertices = w.points[:, :2]
-            plot_convex_polygon(vertices, ax, line_width=linewidth, line_color=c)
diff --git a/artlib/experimental/HullART.py b/artlib/experimental/HullART.py
new file mode 100644
index 0000000..df472b5
--- /dev/null
+++ b/artlib/experimental/HullART.py
@@ -0,0 +1,416 @@
+import numpy as np
+from matplotlib.axes import Axes
+from copy import deepcopy
+from typing import Optional, Iterable, List, Tuple, Union, Dict
+from scipy.spatial import ConvexHull
+from shapely import Polygon
+from alphashape import alphashape
+
+from artlib.common.BaseART import BaseART
+from artlib.experimental.merging import merge_objects
+
+
+def plot_polygon(
+    vertices: np.ndarray,
+    ax: Axes,
+    line_color: str = "b",
+    line_width: float = 1.0,
+):
+    """
+    Plots a convex polygon given its vertices using Matplotlib.
+
+    Parameters
+    ----------
+    vertices : np.ndarray
+        A list of vertices representing a convex polygon.
+    ax : matplotlib.axes.Axes
+        A matplotlib Axes object to plot on.
+    line_color : str, optional
+        The color of the polygon lines, by default 'b'.
+    line_width : float, optional
+        The width of the polygon lines, by default 1.0.
+
+    """
+    vertices = np.array(vertices)
+    # Close the polygon by appending the first vertex at the end
+    vertices = np.vstack([vertices, vertices[0]])
+
+    ax.plot(
+        vertices[:, 0],
+        vertices[:, 1],
+        linestyle="-",
+        color=line_color,
+        linewidth=line_width,
+    )
+
+
+def volume_of_simplex(vertices: np.ndarray) -> float:
+    """
+    Calculates the n-dimensional volume of a simplex defined by its vertices.
+
+    Parameters
+    ----------
+    vertices : np.ndarray
+        An (n+1) x n array representing the coordinates of the simplex vertices.
+
+    Returns
+    -------
+    float
+        Volume of the simplex.
+
+    """
+    vertices = np.asarray(vertices)
+    # Subtract the first vertex from all vertices to form a matrix
+    matrix = vertices[1:] - vertices[0]
+    # Calculate the absolute value of the determinant divided by factorial(n) for volume
+    return np.abs(np.linalg.det(matrix)) / np.math.factorial(len(vertices) - 1)
+
+
+class PseudoConvexHull:
+    def __init__(self, points: np.ndarray):
+        """
+        Initializes a PseudoConvexHull object.
+
+        Parameters
+        ----------
+        points : np.ndarray
+            An array of points representing the convex hull.
+
+        """
+        self.points = points
+
+    @property
+    def vertices(self):
+        return np.array([i for i in range(self.points.shape[0])])
+
+    def add_points(self, points):
+        self.points = np.vstack([self.points, points])
+
+    @property
+    def area(self):
+        if self.points.shape[0] == 1:
+            return 0
+        else:
+            return 2*np.linalg.norm(self.points[0,:]-self.points[1,:],ord=2)
+
+def centroid_of_convex_hull(hull: Union[PseudoConvexHull, ConvexHull]):
+    """
+    Finds the centroid of the volume of a convex hull in n-dimensional space.
+
+    Parameters
+    ----------
+    hull : Union[PseudoConvexHull, ConvexHull]
+        A ConvexHull or PseudoConvexHull object.
+
+    Returns
+    -------
+    np.ndarray
+        Centroid coordinates.
+
+    """
+    hull_vertices = hull.points[hull.vertices]
+
+    centroid = np.zeros(hull_vertices.shape[1])
+    total_volume = 0
+
+    if isinstance(hull, PseudoConvexHull):
+        return np.mean(hull.points, axis=0)
+
+    for simplex in hull.simplices:
+        simplex_vertices = hull_vertices[simplex]
+        simplex_centroid = np.mean(simplex_vertices, axis=0)
+        simplex_volume = volume_of_simplex(simplex_vertices)
+
+        centroid += simplex_centroid * simplex_volume
+        total_volume += simplex_volume
+
+    centroid /= total_volume
+    return centroid
+
+class GeneralHull:
+    def __init__(self, points: np.ndarray, alpha: float = 0.0):
+        self.dim = points.shape[1]
+        self.alpha = alpha
+        if points.shape[0] <= 2:
+            self.hull = PseudoConvexHull(points)
+        elif points.shape[0] == 3 or alpha == 0.0:
+            self.hull = ConvexHull(points, incremental=True)
+        else:
+            self.hull = alphashape(points, alpha=self.alpha)
+
+    def add_points(self, points: np.ndarray):
+        if isinstance(self.hull, PseudoConvexHull):
+            if self.hull.points.shape[0] == 1:
+                self.hull.add_points(points.reshape((-1, self.dim)))
+            else:
+                new_points = np.vstack(
+                    [
+                        self.hull.points[self.hull.vertices, :],
+                        points.reshape((-1, self.dim))
+                    ]
+                )
+                self.hull = ConvexHull(new_points, incremental=True)
+        elif isinstance(self.hull, ConvexHull) and self.alpha == 0.0:
+            self.hull.add_points(i.reshape((-1, self.dim)))
+        else:
+            if isinstance(self.hull, ConvexHull):
+                new_points = np.vstack(
+                    [
+                        self.hull.points[self.hull.vertices, :],
+                        points.reshape((-1, self.dim))
+                    ]
+                )
+                self.hull = alphashape(new_points, alpha=self.alpha)
+            else:
+                new_points = np.vstack(
+                    [
+                        np.asarray(self.hull.exterior.coords),
+                        points.reshape((-1, self.dim))
+                    ]
+                )
+                self.hull = alphashape(new_points, alpha=self.alpha)
+
+    @property
+    def area(self):
+        if isinstance(self.hull, (PseudoConvexHull, ConvexHull)) or self.dim > 2:
+            return self.hull.area
+        else:
+            return self.hull.length
+
+    @property
+    def centroid(self):
+        if isinstance(self.hull, (PseudoConvexHull, ConvexHull)):
+            return centroid_of_convex_hull(self.hull)
+        else:
+            return self.hull.centroid
+
+    @property
+    def is_empty(self):
+        if isinstance(self.hull, (PseudoConvexHull, ConvexHull)):
+            return False
+        else:
+            return self.hull.is_empty
+
+    @property
+    def vertices(self):
+        if isinstance(self.hull, ConvexHull):
+            return self.hull.points[self.hull.vertices, :]
+        elif isinstance(self.hull, Polygon):
+            return np.asarray(self.hull.exterior.coords)
+        else:
+            return self.hull.points
+
+    def deepcopy(self):
+        if isinstance(self.hull, Polygon):
+            points = np.asarray(self.hull.exterior.coords)
+            return GeneralHull(points, alpha=float(self.alpha))
+        elif isinstance(self.hull, ConvexHull):
+            points = self.hull.points[self.hull.vertices, :]
+            return GeneralHull(points, alpha=float(self.alpha))
+        else:
+            return deepcopy(self)
+
+
+class HullART(BaseART):
+    """
+    Hull ART for Clustering
+    """
+
+    def __init__(self, rho: float, alpha: float, alpha_hat: float):
+        """
+        Initializes the HullART object.
+
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
+        alpha : float
+            Choice parameter.
+        alpha_hat : float
+            alpha shape parameter.
+
+        """
+        params = {"rho": rho, "alpha": alpha, "alpha_hat": alpha_hat}
+        super().__init__(params)
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        Validates clustering parameters.
+
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        """
+        assert "rho" in params
+        assert params["rho"] >= 0.0
+        assert isinstance(params["rho"], float)
+        assert "alpha" in params
+        assert params["alpha"] >= 0.0
+        assert isinstance(params["alpha"], float)
+        assert "alpha_hat" in params
+        assert params["alpha_hat"] >= 0.0
+        assert isinstance(params["alpha_hat"], float)
+
+    def category_choice(
+        self, i: np.ndarray, w: GeneralHull, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """
+        Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : GeneralHull
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        float
+            Cluster activation.
+        dict, optional
+            Cache used for later processing.
+
+        """
+
+        new_w = w.deepcopy()
+        new_w.add_points(i.reshape((1,-1)))
+        if new_w.is_empty:
+            raise RuntimeError(
+                f"alpha_hat={params['alpha_hat']} results in invalid geometry"
+            )
+
+        a_max = float(2*len(i))
+        new_area = a_max - new_w.area
+        activation = new_area / (a_max-w.area + params["alpha"])
+
+        cache = {"new_w": new_w, "new_area": new_area, "activation": activation}
+
+        return activation, cache
+
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: GeneralHull,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """
+        Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : GeneralHull
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values cached from previous calculations.
+
+        Returns
+        -------
+        float
+            Cluster match criterion.
+        dict
+            Cache used for later processing.
+
+        """
+        assert cache is not None
+        M = float(cache["new_area"]) / float(2*len(i))
+        cache["match_criterion"] = M
+
+        return M, cache
+
+    def update(
+        self,
+        i: np.ndarray,
+        w: GeneralHull,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> GeneralHull:
+        """
+        Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : GeneralHull
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values cached from previous calculations.
+
+        Returns
+        -------
+        GeneralHull
+            Updated cluster weight.
+
+        """
+        return cache["new_w"]
+
+    def new_weight(self, i: np.ndarray, params: dict) -> GeneralHull:
+        """
+        Generate a new cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        GeneralHull
+            New cluster weight.
+
+        """
+        new_w = GeneralHull(i.reshape((1, -1)), alpha=params["alpha_hat"])
+        return new_w
+
+    def get_cluster_centers(self) -> List[np.ndarray]:
+        """
+        Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
+        """
+        centers = []
+        for w in self.W:
+            centers.append(w.centroid)
+        return centers
+
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: Iterable, linewidth: int = 1
+    ):
+        """
+        Visualize the bounds of each cluster.
+
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
+
+        """
+        for c, w in zip(colors, self.W):
+            vertices = w.vertices[:,:2]
+
+            plot_polygon(
+                vertices, ax, line_width=linewidth, line_color=c
+            )
diff --git a/artlib/experimental/SeqART.py b/artlib/experimental/SeqART.py
index 9900afe..1e052ee 100644
--- a/artlib/experimental/SeqART.py
+++ b/artlib/experimental/SeqART.py
@@ -1,16 +1,72 @@
 import numpy as np
-from typing import Optional, Callable
+from typing import Optional, Callable, Tuple, Union, Dict
 from artlib import BaseART
 import operator
 import re
 
-def compress_dashes(input_string):
-    return re.sub('-+', '-', input_string)
 
-def arr2seq(x):
+def compress_dashes(input_string: str) -> str:
+    """
+    Compress consecutive dashes in a string into a single dash.
+
+    Parameters
+    ----------
+    input_string : str
+        The input string containing dashes.
+
+    Returns
+    -------
+    str
+        The string with consecutive dashes compressed into one dash.
+    """
+    return re.sub("-+", "-", input_string)
+
+
+def arr2seq(x: np.ndarray) -> str:
+    """
+    Convert an array of integers to a string.
+
+    Parameters
+    ----------
+    x : np.ndarray
+        Array of integers to be converted.
+
+    Returns
+    -------
+    str
+        The string representation of the array.
+    """
     return "".join([str(i_) for i_ in x])
 
-def needleman_wunsch(seq1, seq2, match_score=1, gap_cost=-1, mismatch_cost=-1):
+
+def needleman_wunsch(
+    seq1: str,
+    seq2: str,
+    match_score: int = 1,
+    gap_cost: int = -1,
+    mismatch_cost: int = -1,
+) -> Tuple[str, float]:
+    """
+    Perform Needleman-Wunsch sequence alignment between two sequences.
+
+    Parameters
+    ----------
+    seq1 : str
+        The first sequence to align.
+    seq2 : str
+        The second sequence to align.
+    match_score : int, optional
+        The score for a match (default is 1).
+    gap_cost : int, optional
+        The penalty for a gap (default is -1).
+    mismatch_cost : int, optional
+        The penalty for a mismatch (default is -1).
+
+    Returns
+    -------
+    tuple
+        The aligned sequences and the normalized alignment score.
+    """
     m, n = len(seq1), len(seq2)
 
     # Initialize the scoring matrix
@@ -18,160 +74,218 @@ def needleman_wunsch(seq1, seq2, match_score=1, gap_cost=-1, mismatch_cost=-1):
 
     # Initialize the gap penalties for the first row and column
     for i in range(1, m + 1):
-        score_matrix[i][0] = score_matrix[i-1][0] + gap_cost
+        score_matrix[i][0] = score_matrix[i - 1][0] + gap_cost
     for j in range(1, n + 1):
-        score_matrix[0][j] = score_matrix[0][j-1] + gap_cost
+        score_matrix[0][j] = score_matrix[0][j - 1] + gap_cost
 
     # Fill in the scoring matrix
     for i in range(1, m + 1):
         for j in range(1, n + 1):
-            match = score_matrix[i-1][j-1] + (match_score if seq1[i-1] == seq2[j-1] else mismatch_cost)
-            delete = score_matrix[i-1][j] + gap_cost
-            insert = score_matrix[i][j-1] + gap_cost
+            match = score_matrix[i - 1][j - 1] + (
+                match_score if seq1[i - 1] == seq2[j - 1] else mismatch_cost
+            )
+            delete = score_matrix[i - 1][j] + gap_cost
+            insert = score_matrix[i][j - 1] + gap_cost
             score_matrix[i][j] = max(match, delete, insert)
 
     # Traceback to get the alignment
-    align1, align2 = '', ''
+    align1, align2 = "", ""
     i, j = m, n
 
     while i > 0 and j > 0:
         current_score = score_matrix[i][j]
-        diagonal_score = score_matrix[i-1][j-1]
-        up_score = score_matrix[i-1][j]
-        left_score = score_matrix[i][j-1]
-
-        if current_score == diagonal_score + (match_score if seq1[i-1] == seq2[j-1] else mismatch_cost):
-            align1 += seq1[i-1]
-            align2 += seq2[j-1]
+        diagonal_score = score_matrix[i - 1][j - 1]
+        up_score = score_matrix[i - 1][j]
+        left_score = score_matrix[i][j - 1]
+
+        if current_score == diagonal_score + (
+            match_score if seq1[i - 1] == seq2[j - 1] else mismatch_cost
+        ):
+            align1 += seq1[i - 1]
+            align2 += seq2[j - 1]
             i -= 1
             j -= 1
         elif current_score == up_score + gap_cost:
-            align1 += seq1[i-1]
-            align2 += '-'
+            align1 += seq1[i - 1]
+            align2 += "-"
             i -= 1
         elif current_score == left_score + gap_cost:
-            align1 += '-'
-            align2 += seq2[j-1]
+            align1 += "-"
+            align2 += seq2[j - 1]
             j -= 1
 
     # Fill the remaining sequence if the above while loop finishes
     while i > 0:
-        align1 += seq1[i-1]
-        align2 += '-'
+        align1 += seq1[i - 1]
+        align2 += "-"
         i -= 1
 
     while j > 0:
-        align1 += '-'
-        align2 += seq2[j-1]
+        align1 += "-"
+        align2 += seq2[j - 1]
         j -= 1
 
     # The alignments are built from the end to the beginning, so we need to reverse them
     align1 = align1[::-1]
     align2 = align2[::-1]
-    alignment = ''.join([a if a == b else '-' for a, b in zip(align1, align2)])
+    alignment = "".join([a if a == b else "-" for a, b in zip(align1, align2)])
     l = max(len(seq1), len(seq2))
-    return alignment, float(score_matrix[m][n])/l
+
+    return alignment, float(score_matrix[m][n]) / l
 
 
 def prepare_data(data: np.ndarray) -> np.ndarray:
+    """
+    Prepares the data for clustering.
+
+    Parameters
+    ----------
+    data : np.ndarray
+        The input data.
+
+    Returns
+    -------
+    np.ndarray
+        The prepared data.
+    """
     return data
 
 
 class SeqART(BaseART):
-    # template for ART module
+    """
+    Sequence ART for clustering based on sequence alignment.
+    """
+
     def __init__(self, rho: float, metric: Callable = needleman_wunsch):
         """
-        Parameters:
-        - rho: vigilance parameter
-        - metric: allignment function. Should be in the format alignment, score = metric(seq_a, seq_b)
-
+        Initialize the SeqART instance.
+
+        Parameters
+        ----------
+        rho : float
+            The vigilance parameter.
+        metric : Callable, optional
+            The alignment function. Should be in the format: alignment, score = metric(seq_a, seq_b).
         """
         params = {
             "rho": rho,
         }
         self.metric = metric
         super().__init__(params)
+
     @staticmethod
     def validate_params(params: dict):
         """
-        validate clustering parameters
-
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Validate clustering parameters.
 
+        Parameters
+        ----------
+        params : dict
+            The parameters for the algorithm.
         """
         assert "rho" in params
         assert isinstance(params["rho"], float)
 
     def validate_data(self, X: np.ndarray):
         """
-        validates the data prior to clustering
-
-        Parameters:
-        - X: data set
+        Validate the input data for clustering.
 
+        Parameters
+        ----------
+        X : np.ndarray
+            The input data.
         """
         pass
 
     def check_dimensions(self, X: np.ndarray):
         """
-        check the data has the correct dimensions
-
-        Parameters:
-        - X: data set
+        Check that the input data has the correct dimensions.
 
+        Parameters
+        ----------
+        X : np.ndarray
+            The input data.
         """
         pass
 
-
-    def category_choice(self, i: str, w: str, params: dict) -> tuple[float, Optional[dict]]:
+    def category_choice(
+        self, i: str, w: str, params: dict
+    ) -> tuple[float, Optional[dict]]:
         """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            cluster activation, cache used for later processing
-
+        Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : str
+            The data sample.
+        w : str
+            The cluster weight/info.
+        params : dict
+            The algorithm parameters.
+
+        Returns
+        -------
+        tuple
+            Cluster activation and cache used for later processing.
         """
         alignment, score = self.metric(arr2seq(i), w)
-        cache = {'alignment': alignment, 'score': score}
+        cache = {"alignment": alignment, "score": score}
         return score, cache
 
-    def match_criterion(self, i: str, w: str, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
+    def match_criterion(
+        self, i: str, w: str, params: dict, cache: Optional[dict] = None
+    ) -> Tuple[float, Optional[Dict]]:
         """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
-
+        Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : str
+            The data sample.
+        w : str
+            The cluster weight/info.
+        params : dict
+            The algorithm parameters.
+        cache : dict, optional
+            Cached values from previous calculations.
+
+        Returns
+        -------
+        tuple
+            Cluster match criterion and cache used for later processing.
         """
         # _, M = self.metric(cache['alignment'], w)
 
-        return cache['score'], cache
+        return cache["score"], cache
 
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, op: Callable = operator.ge) -> tuple[bool, dict]:
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+        op: Callable = operator.ge,
+    ) -> tuple[bool, dict]:
         """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion binary, cache used for later processing
-
+        Get the binary match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            The data sample.
+        w : np.ndarray
+            The cluster weight/info.
+        params : dict
+            The algorithm parameters.
+        cache : dict, optional
+            Cached values from previous calculations.
+        op : Callable, optional
+            Comparison operator for the match criterion (default is operator.ge).
+
+        Returns
+        -------
+        tuple
+            Binary match criterion and cache used for later processing.
         """
         M, cache = self.match_criterion(arr2seq(i), w, params, cache)
         M_bin = op(M, params["rho"])
@@ -182,34 +296,45 @@ def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache:
 
         return M_bin, cache
 
-    def update(self, i: str, w: str, params: dict, cache: Optional[dict] = None) -> str:
+    def update(
+        self, i: str, w: str, params: dict, cache: Optional[dict] = None
+    ) -> str:
         """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
-
+        Update the cluster weight.
+
+        Parameters
+        ----------
+        i : str
+            The data sample.
+        w : str
+            The cluster weight/info.
+        params : dict
+            The algorithm parameters.
+        cache : dict, optional
+            Cached values from previous calculations.
+
+        Returns
+        -------
+        str
+            Updated cluster weight.
         """
         # print(cache['alignment'])
-        return compress_dashes(cache['alignment'])
+        return compress_dashes(cache["alignment"])
 
     def new_weight(self, i: str, params: dict) -> str:
         """
-        generate a new cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-
-        Returns:
-            updated cluster weight
-
+        Generate a new cluster weight.
+
+        Parameters
+        ----------
+        i : str
+            The data sample.
+        params : dict
+            The algorithm parameters.
+
+        Returns
+        -------
+        str
+            New cluster weight.
         """
-        return arr2seq(i)
\ No newline at end of file
+        return arr2seq(i)
diff --git a/artlib/experimental/__init__.py b/artlib/experimental/__init__.py
index e69de29..748227e 100644
--- a/artlib/experimental/__init__.py
+++ b/artlib/experimental/__init__.py
@@ -0,0 +1,3 @@
+"""
+Code within this module is highly experimental and therefore comes with no warranty on its use.
+"""
diff --git a/artlib/experimental/merging.py b/artlib/experimental/merging.py
index 615a9d0..bb25f18 100644
--- a/artlib/experimental/merging.py
+++ b/artlib/experimental/merging.py
@@ -1,4 +1,23 @@
-def find(parent, i):
+from typing import List, Callable
+
+
+def find(parent: List[int], i: int) -> int:
+    """
+    Find the root of the set containing element i using path compression.
+
+    Parameters
+    ----------
+    parent : list
+        List representing the parent of each element.
+    i : int
+        The element to find the root of.
+
+    Returns
+    -------
+    int
+        The root of the set containing element i.
+
+    """
     if parent[i] == i:
         return i
     else:
@@ -6,7 +25,22 @@ def find(parent, i):
         return parent[i]
 
 
-def union(parent, rank, x, y):
+def union(parent: List[int], rank: list[int], x: int, y: int):
+    """
+    Perform union of two sets containing elements x and y using union by rank.
+
+    Parameters
+    ----------
+    parent : list
+        List representing the parent of each element.
+    rank : list
+        List representing the rank (depth) of each tree.
+    x : int
+        The first element.
+    y : int
+        The second element.
+
+    """
     root_x = find(parent, x)
     root_y = find(parent, y)
 
@@ -21,7 +55,23 @@ def union(parent, rank, x, y):
             rank[root_x] += 1
 
 
-def merge_objects(objects, can_merge):
+def merge_objects(objects: List, can_merge: Callable):
+    """
+    Merge objects into groups based on a merge condition function using Union-Find algorithm.
+
+    Parameters
+    ----------
+    objects : list
+        List of objects to be merged.
+    can_merge : callable
+        A function that takes two objects and returns True if they can be merged.
+
+    Returns
+    -------
+    list of list
+        A list of merged groups, where each group is a list of object indices.
+
+    """
     # Initialize Union-Find structure
     n = len(objects)
     parent = list(range(n))
diff --git a/artlib/fusion/FusionART.py b/artlib/fusion/FusionART.py
index 0728680..781e66a 100644
--- a/artlib/fusion/FusionART.py
+++ b/artlib/fusion/FusionART.py
@@ -1,19 +1,36 @@
-"""
-Tan, A.-H., Carpenter, G. A., & Grossberg, S. (2007).
-Intelligence Through Interaction: Towards a Unified Theory for Learning.
-In D. Liu, S. Fei, Z.-G. Hou, H. Zhang, & C. Sun (Eds.),
-Advances in Neural Networks – ISNN 2007 (pp. 1094–1103).
-Berlin, Heidelberg: Springer Berlin Heidelberg.
-doi:10.1007/ 978-3-540-72383-7_128.
-"""
+"""Fusion ART :cite:`tan2007intelligence`."""
+# Tan, A.-H., Carpenter, G. A., & Grossberg, S. (2007).
+# Intelligence Through Interaction: Towards a Unified Theory for Learning.
+# In D. Liu, S. Fei, Z.-G. Hou, H. Zhang, & C. Sun (Eds.),
+# Advances in Neural Networks – ISNN 2007 (pp. 1094–1103).
+# Berlin, Heidelberg: Springer Berlin Heidelberg.
+# doi:10.1007/ 978-3-540-72383-7_128.
+
 import numpy as np
-from typing import Optional, Union, Callable, List, Literal, Tuple
+from typing import Optional, Union, Callable, List, Literal, Tuple, Dict
 from copy import deepcopy
 from artlib.common.BaseART import BaseART
 from sklearn.utils.validation import check_is_fitted
 import operator
 
-def get_channel_position_tuples(channel_dims: list[int]) -> list[tuple[int, int]]:
+
+def get_channel_position_tuples(
+    channel_dims: List[int],
+) -> List[Tuple[int, int]]:
+    """Generate the start and end positions for each channel in the input data.
+
+    Parameters
+    ----------
+    channel_dims : list of int
+        A list representing the number of dimensions for each channel.
+
+    Returns
+    -------
+    list of tuple of int
+        A list of tuples where each tuple represents the start and end index for a
+        channel.
+
+    """
     positions = []
     start = 0
     for length in channel_dims:
@@ -22,15 +39,48 @@ def get_channel_position_tuples(channel_dims: list[int]) -> list[tuple[int, int]
         start = end
     return positions
 
+
 class FusionART(BaseART):
-    # implementation of FusionART
+    """Fusion ART for Data Fusion and Regression.
+
+    This module implements Fusion ART as first described in:
+    :cite:`tan2007intelligence`.
+
+    .. # Tan, A.-H., Carpenter, G. A., & Grossberg, S. (2007).
+    .. # Intelligence Through Interaction: Towards a Unified Theory for Learning.
+    .. # In D. Liu, S. Fei, Z.-G. Hou, H. Zhang, & C. Sun (Eds.),
+    .. # Advances in Neural Networks – ISNN 2007 (pp. 1094–1103).
+    .. # Berlin, Heidelberg: Springer Berlin Heidelberg.
+    .. # doi:10.1007/ 978-3-540-72383-7_128.
+
+    Fusion ART accepts an arbitrary number of ART modules, each assigned a different
+    data channel. The activation and match functions for all ART modules are then fused
+    such that all modules must be simultaneously active and resonant in order for a
+    match to occur. This provides fine-grain control when clustering multi-channel or
+    multi-modal data and allows for different geometries of clusters to be used for
+    each channel. Fusion ART also allows for fitting regression models and specific
+    functions have been implemented to allow this.
+
+    """
 
     def __init__(
-            self,
-            modules: list[BaseART],
-            gamma_values: Union[list[float], np.ndarray],
-            channel_dims: Union[list[int], np.ndarray]
+        self,
+        modules: List[BaseART],
+        gamma_values: Union[List[float], np.ndarray],
+        channel_dims: Union[List[int], np.ndarray],
     ):
+        """Initialize the FusionART instance.
+
+        Parameters
+        ----------
+        modules : List[BaseART]
+            A list of ART modules corresponding to each data channel.
+        gamma_values : Union[List[float], np.ndarray]
+            The activation ratio for each channel.
+        channel_dims : Union[List[int], np.ndarray]
+            The number of dimensions for each channel.
+
+        """
         assert len(modules) == len(gamma_values) == len(channel_dims)
         params = {"gamma_values": gamma_values}
         super().__init__(params)
@@ -40,13 +90,18 @@ def __init__(
         self._channel_indices = get_channel_position_tuples(self.channel_dims)
         self.dim_ = sum(channel_dims)
 
-    def get_params(self, deep: bool = True) -> dict:
-        """
+    def get_params(self, deep: bool = True) -> Dict:
+        """Get the parameters of the FusionART model.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+        Parameters
+        ----------
+        deep : bool, optional
+            If True, will return parameters for this class and the contained sub-objects
+            that are estimators (default is True).
 
-        Returns:
+        Returns
+        -------
+        dict
             Parameter names mapped to their values.
 
         """
@@ -59,128 +114,267 @@ def get_params(self, deep: bool = True) -> dict:
 
     @property
     def n_clusters(self) -> int:
+        """Return the number of clusters in the first ART module.
+
+        Returns
+        -------
+        int
+            The number of clusters.
+
+        """
         return self.modules[0].n_clusters
 
     @property
     def W(self):
+        """Get the weights of all modules as a single array.
+
+        Returns
+        -------
+        np.ndarray
+            Concatenated weights of all channels from the ART modules.
+
+        """
         W = [
-            np.concatenate(
-                [
-                    self.modules[k].W[i]
-                    for k in range(self.n)
-                 ]
-            )
-            for i
-            in range(self.modules[0].n_clusters)
+            np.concatenate([self.modules[k].W[i] for k in range(self.n)])
+            for i in range(self.modules[0].n_clusters)
         ]
         return W
 
     @W.setter
     def W(self, new_W):
+        """Set the weights for each module by splitting the input weights.
+
+        Parameters
+        ----------
+        new_W : np.ndarray
+            New concatenated weights to be set for the modules.
+
+        """
         for k in range(self.n):
             if len(new_W) > 0:
-                self.modules[k].W = new_W[self._channel_indices[k][0]:self._channel_indices[k][1]]
+                self.modules[k].W = new_W[
+                    self._channel_indices[k][0] : self._channel_indices[k][1]
+                ]
             else:
                 self.modules[k].W = []
 
     @staticmethod
-    def validate_params(params: dict):
-        """
-        validate clustering parameters
+    def validate_params(params: Dict):
+        """Validate clustering parameters.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        params : dict
+            The parameters for the FusionART model.
 
         """
         assert "gamma_values" in params
         assert all([1.0 >= g >= 0.0 for g in params["gamma_values"]])
         assert sum(params["gamma_values"]) == 1.0
-        assert isinstance(params["gamma_values"], np.ndarray)
-
+        assert isinstance(params["gamma_values"], (np.ndarray, list))
 
     def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the input data for clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
 
         """
         self.check_dimensions(X)
         for k in range(self.n):
-            X_k = X[:, self._channel_indices[k][0]:self._channel_indices[k][1]]
+            X_k = X[:, self._channel_indices[k][0] : self._channel_indices[k][1]]
             self.modules[k].validate_data(X_k)
 
     def check_dimensions(self, X: np.ndarray):
-        """
-        check the data has the correct dimensions
+        """Ensure that the input data has the correct dimensions.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
 
         """
         assert X.shape[1] == self.dim_, "Invalid data shape"
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict, skip_channels: List[int] = []) -> tuple[float, Optional[dict]]:
+    def prepare_data(
+        self, channel_data: List[np.ndarray], skip_channels: List[int] = []
+    ) -> np.ndarray:
+        """Prepare the input data by processing each channel's data through its
+        respective ART module.
+
+        Parameters
+        ----------
+        channel_data : list of np.ndarray
+            List of arrays, one for each channel.
+        skip_channels : list of int, optional
+            Channels to be skipped (default is []).
+
+        Returns
+        -------
+        np.ndarray
+            Processed and concatenated data.
+
         """
-        get the activation of the cluster
+        skip_channels = [self.n + k if k < 0 else k for k in skip_channels]
+        prepared_channel_data = [
+            self.modules[i].prepare_data(channel_data[i])
+            for i in range(self.n)
+            if i not in skip_channels
+        ]
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        return self.join_channel_data(
+            prepared_channel_data, skip_channels=skip_channels
+        )
 
-        Returns:
-            cluster activation, cache used for later processing
+    def restore_data(
+        self, X: np.ndarray, skip_channels: List[int] = []
+    ) -> List[np.ndarray]:
+        """Restore data to its original state before preparation.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The prepared data.
+        skip_channels : list of int, optional
+            Channels to be skipped (default is []).
+        Returns
+        -------
+        np.ndarray
+            Restored data for each channel.
+
+        """
+        skip_channels = [self.n + k if k < 0 else k for k in skip_channels]
+        channel_data = self.split_channel_data(X, skip_channels=skip_channels)
+        restored_channel_data = [
+            self.modules[i].restore_data(channel_data[i])
+            for i in range(self.n)
+            if i not in skip_channels
+        ]
+        return restored_channel_data
+
+    def category_choice(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        skip_channels: List[int] = [],
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            The data sample.
+        w : np.ndarray
+            The cluster weight information.
+        params : dict
+            Parameters for the ART algorithm.
+        skip_channels : list of int, optional
+            Channels to be skipped (default is []).
+
+        Returns
+        -------
+        tuple
+            Cluster activation and cache for further processing.
 
         """
         activations, caches = zip(
             *[
                 self.modules[k].category_choice(
-                    i[self._channel_indices[k][0]:self._channel_indices[k][1]],
-                    w[self._channel_indices[k][0]:self._channel_indices[k][1]],
-                    self.modules[k].params
+                    i[self._channel_indices[k][0] : self._channel_indices[k][1]],
+                    w[self._channel_indices[k][0] : self._channel_indices[k][1]],
+                    self.modules[k].params,
                 )
                 if k not in skip_channels
-                else (1., dict())
+                else (1.0, dict())
                 for k in range(self.n)
             ]
         )
         cache = {k: cache_k for k, cache_k in enumerate(caches)}
-        activation = sum([a*self.params["gamma_values"][k] for k, a in enumerate(activations)])
+        activation = sum(
+            [a * self.params["gamma_values"][k] for k, a in enumerate(activations)]
+        )
         return activation, cache
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, skip_channels: List[int] = []) -> tuple[list[float], dict]:
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+        skip_channels: List[int] = [],
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion for the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            The data sample.
+        w : np.ndarray
+            The cluster weight information.
+        params : dict
+            Parameters for the ART algorithm.
+        cache : dict, optional
+            Cache for previous calculations (default is None).
+        skip_channels : list of int, optional
+            Channels to be skipped (default is []).
+
+        Returns
+        -------
+        tuple
+            max match_criterion across channels and the updated cache.
+
+        """
         if cache is None:
             raise ValueError("No cache provided")
         M, caches = zip(
             *[
                 self.modules[k].match_criterion(
-                    i[self._channel_indices[k][0]:self._channel_indices[k][1]],
-                    w[self._channel_indices[k][0]:self._channel_indices[k][1]],
+                    i[self._channel_indices[k][0] : self._channel_indices[k][1]],
+                    w[self._channel_indices[k][0] : self._channel_indices[k][1]],
                     self.modules[k].params,
-                    cache[k]
+                    cache[k],
                 )
                 if k not in skip_channels
-                else (np.inf, {"match_criterion": np.inf})
+                else (np.nan, {"match_criterion": np.inf})
                 for k in range(self.n)
             ]
         )
         cache = {k: cache_k for k, cache_k in enumerate(caches)}
-        return M, cache
-
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, skip_channels: List[int] = [], op: Callable = operator.ge) -> tuple[bool, dict]:
-        """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion binary, cache used for later processing
+        return np.nanmax(M), cache
+
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+        op: Callable = operator.ge,
+        skip_channels: List[int] = [],
+    ) -> Tuple[bool, Dict]:
+        """Get the binary match criterion for the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            The data sample.
+        w : np.ndarray
+            The cluster weight information.
+        params : dict
+            Parameters for the ART algorithm.
+        cache : dict, optional
+            Cache for previous calculations (default is None).
+        op : Callable, optional
+            Operator for comparison (default is operator.ge).
+        skip_channels : list of int, optional
+            Channels to be skipped (default is []).
+
+        Returns
+        -------
+        tuple
+            Binary match criterion and cache for further processing.
 
         """
         if cache is None:
@@ -188,11 +382,11 @@ def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache:
         M_bin, caches = zip(
             *[
                 self.modules[k].match_criterion_bin(
-                    i[self._channel_indices[k][0]:self._channel_indices[k][1]],
-                    w[self._channel_indices[k][0]:self._channel_indices[k][1]],
+                    i[self._channel_indices[k][0] : self._channel_indices[k][1]],
+                    w[self._channel_indices[k][0] : self._channel_indices[k][1]],
                     self.modules[k].params,
                     cache[k],
-                    op
+                    op,
                 )
                 if k not in skip_channels
                 else (True, {"match_criterion": np.inf})
@@ -202,89 +396,154 @@ def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache:
         cache = {k: cache_k for k, cache_k in enumerate(caches)}
         return all(M_bin), cache
 
+    def _match_tracking(
+        self,
+        cache: Union[List[Dict], Dict],
+        epsilon: float,
+        params: Union[List[Dict], Dict],
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Perform match tracking for all channels using the specified method.
+
+        Parameters
+        ----------
+        cache : list of dict
+            Cached match criterion values for each channel.
+        epsilon : float
+            Small adjustment factor for match tracking.
+        params : list of dict
+            Parameters for each channel module.
+        method : Literal["MT+", "MT-", "MT0", "MT1", "MT~"]
+            Match tracking method to apply.
+
+        Returns
+        -------
+        bool
+            Whether to continue searching for a match across all channels.
 
-    def _match_tracking(self, cache: List[dict], epsilon: float, params: List[dict], method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
+        """
         keep_searching = []
         for i in range(len(cache)):
             if cache[i]["match_criterion_bin"]:
-                keep_searching_i = self.modules[i]._match_tracking(cache[i], epsilon, params[i], method)
+                keep_searching_i = self.modules[i]._match_tracking(
+                    cache[i], epsilon, params[i], method
+                )
                 keep_searching.append(keep_searching_i)
             else:
                 keep_searching.append(True)
         return all(keep_searching)
 
+    def _set_params(self, new_params: List[Dict]):
+        """Set the parameters for each module in FusionART.
 
-    def _set_params(self, new_params):
+        Parameters
+        ----------
+        new_params : list of dict
+            A list of parameters for each module.
+
+        """
         for i in range(self.n):
             self.modules[i].params = new_params[i]
 
-    def _deep_copy_params(self):
-        return {i: deepcopy(module.params) for i, module in enumerate(self.modules)}
+    def _deep_copy_params(self) -> Dict:
+        """Create a deep copy of the parameters for each module.
 
+        Returns
+        -------
+        dict
+            A dictionary with module indices as keys and their deep-copied parameters
+            as values.
 
-    def partial_fit(self, X: np.ndarray, match_reset_func: Optional[Callable] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
         """
-        iteratively fit the model to the data
+        return {i: deepcopy(module.params) for i, module in enumerate(self.modules)}
 
-        Parameters:
-        - X: data set
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Iteratively fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Input dataset.
+        match_reset_func : callable, optional
+            Function to reset the match criteria based on external factors.
+        match_tracking : Literal["MT+", "MT-", "MT0", "MT1", "MT~"], optional
+            Method for resetting match criteria (default is "MT+").
+        epsilon : float, optional
+            Value to adjust the vigilance parameter (default is 0.0).
 
         """
-
         self.validate_data(X)
         self.check_dimensions(X)
-        self.is_fitted_ =  True
+        self.is_fitted_ = True
 
-        if not hasattr(self.modules[0], 'W'):
-            self.W: list[np.ndarray] = []
-            self.labels_ = np.zeros((X.shape[0], ), dtype=int)
+        if not hasattr(self.modules[0], "W"):
+            self.W: List[np.ndarray] = []
+            self.labels_ = np.zeros((X.shape[0],), dtype=int)
             j = 0
         else:
             j = len(self.labels_)
-            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode='constant')
+            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode="constant")
         for i, x in enumerate(X):
-            c = self.step_fit(x, match_reset_func=match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
-            self.labels_[i+j] = c
+            c = self.step_fit(
+                x,
+                match_reset_func=match_reset_func,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
+            self.labels_[i + j] = c
         return self
 
     def step_pred(self, x, skip_channels: List[int] = []) -> int:
-        """
-        predict the label for a single sample
+        """Predict the label for a single sample.
 
-        Parameters:
-        - x: data sample
+        Parameters
+        ----------
+        x : np.ndarray
+            Input sample.
+        skip_channels : list of int, optional
+            Channels to skip (default is []).
 
-        Returns:
-            cluster label of the input sample
+        Returns
+        -------
+        int
+            Predicted cluster label for the input sample.
 
         """
         assert len(self.W) >= 0, "ART module is not fit."
 
-        T, _ = zip(*[self.category_choice(x, w, params=self.params, skip_channels=skip_channels) for w in self.W])
+        T, _ = zip(
+            *[
+                self.category_choice(
+                    x, w, params=self.params, skip_channels=skip_channels
+                )
+                for w in self.W
+            ]
+        )
         c_ = int(np.argmax(T))
         return c_
 
     def predict(self, X: np.ndarray, skip_channels: List[int] = []) -> np.ndarray:
-        """
-        predict labels for the data
+        """Predict labels for the input data.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Input dataset.
+        skip_channels : list of int, optional
+            Channels to skip (default is []).
 
-        Returns:
-            labels for the data
+        Returns
+        -------
+        np.ndarray
+            Predicted labels for the input data.
 
         """
-
         check_is_fitted(self)
         self.validate_data(X)
         self.check_dimensions(X)
@@ -295,47 +554,63 @@ def predict(self, X: np.ndarray, skip_channels: List[int] = []) -> np.ndarray:
             y[i] = c
         return y
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+    ) -> np.ndarray:
+        """Update the cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Input data sample.
+        w : np.ndarray
+            Cluster weight information.
+        params : dict
+            Parameters for the ART algorithm.
+        cache : dict, optional
+            Cache for previous calculations (default is None).
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
+        assert cache is not None
         W = [
             self.modules[k].update(
-                i[self._channel_indices[k][0]:self._channel_indices[k][1]],
-                w[self._channel_indices[k][0]:self._channel_indices[k][1]],
+                i[self._channel_indices[k][0] : self._channel_indices[k][1]],
+                w[self._channel_indices[k][0] : self._channel_indices[k][1]],
                 self.modules[k].params,
-                cache[k]
+                cache[k],
             )
             for k in range(self.n)
         ]
         return np.concatenate(W)
 
-    def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+    def new_weight(self, i: np.ndarray, params: Dict) -> np.ndarray:
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Input data sample.
+        params : dict
+            Parameters for the ART algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            New cluster weight.
 
         """
         W = [
             self.modules[k].new_weight(
-                i[self._channel_indices[k][0]:self._channel_indices[k][1]],
+                i[self._channel_indices[k][0] : self._channel_indices[k][1]],
                 self.modules[k].params,
             )
             for k in range(self.n)
@@ -343,20 +618,18 @@ def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
         return np.concatenate(W)
 
     def add_weight(self, new_w: np.ndarray):
-        """
-        add a new cluster weight
+        """Add a new cluster weight.
 
         Parameters:
         - new_w: new cluster weight to add
 
         """
         for k in range(self.n):
-            new_w_k = new_w[self._channel_indices[k][0]:self._channel_indices[k][1]]
+            new_w_k = new_w[self._channel_indices[k][0] : self._channel_indices[k][1]]
             self.modules[k].add_weight(new_w_k)
 
     def set_weight(self, idx: int, new_w: np.ndarray):
-        """
-        set the value of a cluster weight
+        """Set the value of a cluster weight.
 
         Parameters:
         - idx: index of cluster to update
@@ -364,33 +637,64 @@ def set_weight(self, idx: int, new_w: np.ndarray):
 
         """
         for k in range(self.n):
-            new_w_k = new_w[self._channel_indices[k][0]:self._channel_indices[k][1]]
+            new_w_k = new_w[self._channel_indices[k][0] : self._channel_indices[k][1]]
             self.modules[k].set_weight(idx, new_w_k)
 
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
+        """Get the center points for each cluster.
+
+        Returns
+        -------
+        list of np.ndarray
+            Center points of the clusters.
+
         """
         centers_ = [module.get_cluster_centers() for module in self.modules]
         centers = [
-            np.concatenate(
-                [
-                    centers_[k][i]
-                    for k in range(self.n)
-                ]
-            )
-            for i
-            in range(self.n_clusters)
+            np.concatenate([centers_[k][i] for k in range(self.n)])
+            for i in range(self.n_clusters)
         ]
         return centers
 
-    def get_channel_centers(self, channel: int):
+    def get_channel_centers(self, channel: int) -> List[np.ndarray]:
+        """Get the center points of clusters for a specific channel.
+
+        Parameters
+        ----------
+        channel : int
+            The channel index.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centers for the specified channel.
+
+        """
         return self.modules[channel].get_cluster_centers()
 
-    def predict_regression(self, X: np.ndarray, target_channels: List[int] = [-1]) -> Union[np.ndarray, List[np.ndarray]]:
-        target_channels = [self.n+k if k < 0 else k for k in target_channels]
+    def predict_regression(
+        self, X: np.ndarray, target_channels: List[int] = [-1]
+    ) -> Union[np.ndarray, List[np.ndarray]]:
+        """Predict regression values for the input data using the target channels.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Input dataset.
+        target_channels : list of int, optional
+            List of target channels to use for regression. If negative values are used,
+            they are considered as channels counting backward from the last channel.
+            By default, it uses the last channel (-1).
+
+        Returns
+        -------
+        Union[np.ndarray, list of np.ndarray]
+            Predicted regression values. If only one target channel is used, returns a
+            single np.ndarray. If multiple target channels are used, returns a list of
+            np.ndarray, one for each channel.
+
+        """
+        target_channels = [self.n + k if k < 0 else k for k in target_channels]
         C = self.predict(X, skip_channels=target_channels)
         centers = [self.get_channel_centers(k) for k in target_channels]
         if len(target_channels) == 1:
@@ -398,8 +702,25 @@ def predict_regression(self, X: np.ndarray, target_channels: List[int] = [-1]) -
         else:
             return [np.array([centers[k][c] for c in C]) for k in target_channels]
 
-    def join_channel_data(self, channel_data: List[np.ndarray], skip_channels: List[int] = []) -> np.ndarray:
-        skip_channels = [self.n+k if k < 0 else k for k in skip_channels]
+    def join_channel_data(
+        self, channel_data: List[np.ndarray], skip_channels: List[int] = []
+    ) -> np.ndarray:
+        """Concatenate data from different channels into a single array.
+
+        Parameters
+        ----------
+        channel_data : list of np.ndarray
+            Data from each channel.
+        skip_channels : list of int, optional
+            Channels to skip (default is []).
+
+        Returns
+        -------
+        np.ndarray
+            Concatenated data.
+
+        """
+        skip_channels = [self.n + k if k < 0 else k for k in skip_channels]
         n_samples = channel_data[0].shape[0]
 
         formatted_channel_data = []
@@ -409,7 +730,55 @@ def join_channel_data(self, channel_data: List[np.ndarray], skip_channels: List[
                 formatted_channel_data.append(channel_data[i])
                 i += 1
             else:
-                formatted_channel_data.append(0.5*np.ones((n_samples, self._channel_indices[k][1]-self._channel_indices[k][0])))
+                formatted_channel_data.append(
+                    0.5
+                    * np.ones(
+                        (
+                            n_samples,
+                            self._channel_indices[k][1] - self._channel_indices[k][0],
+                        )
+                    )
+                )
 
         X = np.hstack(formatted_channel_data)
-        return X
\ No newline at end of file
+        return X
+
+    def split_channel_data(
+        self, joined_data: np.ndarray, skip_channels: List[int] = []
+    ) -> List[np.ndarray]:
+        """Split the concatenated data into its original channels.
+
+        Parameters
+        ----------
+        joined_data : np.ndarray
+            Concatenated data from multiple channels.
+        skip_channels : list of int, optional
+            Channels to skip (default is []).
+
+        Returns
+        -------
+        list of np.ndarray
+            Split data, one array for each channel.
+
+        """
+        skip_channels = [self.n + k if k < 0 else k for k in skip_channels]
+
+        channel_data = []
+        current_col = 0
+
+        for k in range(self.n):
+            start_idx, end_idx = self._channel_indices[k]
+            channel_width = end_idx - start_idx
+
+            if k not in skip_channels:
+                # Extract the original channel data
+                channel_data.append(
+                    joined_data[:, current_col : current_col + channel_width]
+                )
+                current_col += channel_width
+            else:
+                # If this channel was skipped, we know it was filled with 0.5,
+                # so we skip those columns
+                current_col += channel_width
+
+        return channel_data
diff --git a/artlib/fusion/__init__.py b/artlib/fusion/__init__.py
index e69de29..41374a6 100644
--- a/artlib/fusion/__init__.py
+++ b/artlib/fusion/__init__.py
@@ -0,0 +1,17 @@
+"""Data fusion is the process of integrating multiple data sources to produce more
+consistent, accurate, and useful information than would be possible when using the
+sources independently. It is used in a wide range of fields, including sensor networks,
+image processing, and decision-making systems.
+
+The ART module contained herein allows for the fusion of an arbitrary number of data
+channels. This structure not only supports classification tasks but also enables it to
+be used for regression on polytonic (as opposed to monotonic) problems. By leveraging
+data from multiple channels, the module improves regression accuracy by combining
+diverse information sources, making it particularly suited for complex problems where
+single-channel approaches fall short.
+
+This is the recommended module for such regression problems.
+
+`Data fusion <https://en.wikipedia.org/wiki/Data_fusion>`_
+
+"""
diff --git a/artlib/hierarchical/DeepARTMAP.py b/artlib/hierarchical/DeepARTMAP.py
index 61694c8..b11475a 100644
--- a/artlib/hierarchical/DeepARTMAP.py
+++ b/artlib/hierarchical/DeepARTMAP.py
@@ -1,8 +1,8 @@
-"""
-Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
-ARTMAP: Supervised real-time learning and classification of nonstationary data by a self-organizing neural network.
-Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
-"""
+"""Deep ARTMAP :cite:`carpenter1991artmap`."""
+# Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
+# ARTMAP: Supervised real-time learning and classification of nonstationary data by a
+# self-organizing neural network.
+# Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
 import numpy as np
 from sklearn.base import BaseEstimator, ClassifierMixin, ClusterMixin
 from typing import Optional, cast, Union, Literal, Tuple
@@ -12,27 +12,57 @@
 from artlib.supervised.SimpleARTMAP import SimpleARTMAP
 from artlib.supervised.ARTMAP import ARTMAP
 
+
 class DeepARTMAP(BaseEstimator, ClassifierMixin, ClusterMixin):
+    """DeepARTMAP for Hierachical Supervised and Unsupervised Learning.
+
+    This module implements DeepARTMAP, a generalization of the
+    :class:`~artlib.supervised.ARTMAP.ARTMAP` class :cite:`carpenter1991artmap` that
+    allows an arbitrary number of data channels to be divisively clustered. DeepARTMAP
+    support both supervised and unsupervised modes. If only two ART modules are
+    provided, DeepARTMAP reverts to standard :class:`~artlib.supervised.ARTMAP.ARTMAP`
+    where the first module is the A-module and the second module is the B-module.
+    DeepARTMAP does not currently have a direct citation and is an original creation
+    of this library.
+
+    .. # Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
+    .. # ARTMAP: Supervised real-time learning and classification of nonstationary data
+    .. # by a self-organizing neural network.
+    .. # Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
+
+    """
 
     def __init__(self, modules: list[BaseART]):
-        """
+        """Initialize the DeepARTMAP model.
+
+        Parameters
+        ----------
+        modules : list of BaseART
+            A list of instantiated ART modules to use as layers in the DeepARTMAP model.
 
-        Parameters:
-        - modules: list of ART modules
+        Raises
+        ------
+        AssertionError
+            If no ART modules are provided.
 
         """
         assert len(modules) >= 1, "Must provide at least one ART module"
         self.modules = modules
-        self.layers: list[BaseARTMAP]
+        self.layers: list[BaseARTMAP] = []
         self.is_supervised: Optional[bool] = None
 
     def get_params(self, deep: bool = True) -> dict:
-        """
+        """Get parameters for this estimator.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+        Parameters
+        ----------
+        deep : bool, optional, default=True
+            If True, will return the parameters for this class and contained subobjects
+            that are estimators.
 
-        Returns:
+        Returns
+        -------
+        dict
             Parameter names mapped to their values.
 
         """
@@ -47,15 +77,17 @@ def get_params(self, deep: bool = True) -> dict:
     def set_params(self, **params):
         """Set the parameters of this estimator.
 
-        Specific redefinition of sklearn.BaseEstimator.set_params for ARTMAP classes
+        Parameters
+        ----------
+        **params : dict
+            Estimator parameters.
 
-        Parameters:
-        - **params : Estimator parameters.
+        Returns
+        -------
+        self : DeepARTMAP
+            The estimator instance.
 
-        Returns:
-        - self : estimator instance
         """
-
         if not params:
             # Simple optimization to gain speed (inspect is slow)
             return self
@@ -84,182 +116,312 @@ def set_params(self, **params):
         return self
 
     @property
-    def labels_(self):
+    def labels_(self) -> np.ndarray:
+        """Get the labels from the first layer.
+
+        Returns
+        -------
+        np.ndarray
+            The labels from the first ART layer.
+
+        """
         return self.layers[0].labels_
 
     @property
-    def labels_deep_(self):
+    def labels_deep_(self) -> np.ndarray:
+        """Get the deep labels from all layers.
+
+        Returns
+        -------
+        np.ndarray
+            Deep labels from all ART layers concatenated together.
+
+        """
         return np.concatenate(
-            [
-                layer.labels_.reshape((-1, 1))
-                for layer in self.layers
-            ]+[
-                self.layers[-1].labels_a.reshape((-1, 1))
-            ],
-            axis=1
+            [layer.labels_.reshape((-1, 1)) for layer in self.layers]
+            + [self.layers[-1].labels_a.reshape((-1, 1))],
+            axis=1,
         )
 
     @property
-    def n_modules(self):
+    def n_modules(self) -> int:
+        """Get the number of ART modules.
+
+        Returns
+        -------
+        int
+            The number of ART modules.
+
+        """
         return len(self.modules)
 
     @property
-    def n_layers(self):
-        return len(self.layers)
+    def n_layers(self) -> int:
+        """Get the number of layers.
+
+        Returns
+        -------
+        int
+            The number of layers in DeepARTMAP.
 
-    def map_deep(self, level: int, y_a: Union[np.ndarray, int]) -> Union[np.ndarray, int]:
         """
-        map a label from one arbitrary level to the highest (B) level
+        return len(self.layers)
+
+    def map_deep(
+        self, level: int, y_a: Union[np.ndarray, int]
+    ) -> Union[np.ndarray, int]:
+        """Map a label from one arbitrary level to the highest (B) level.
 
-        Parameters:
-        - level: level the label is from
-        - y_a: the cluster label(s)
+        Parameters
+        ----------
+        level : int
+            The level from which the label is taken.
+        y_a : np.ndarray or int
+            The cluster label(s) at the input level.
 
-        Returns:
-            cluster label(s) at highest level
+        Returns
+        -------
+        np.ndarray or int
+            The cluster label(s) at the highest level (B).
 
         """
         if level < 0:
             level += len(self.layers)
         y_b = self.layers[level].map_a2b(y_a)
         if level > 0:
-            return self.map_deep(level-1, y_b)
+            return self.map_deep(level - 1, y_b)
         else:
             return y_b
 
+    def validate_data(self, X: list[np.ndarray], y: Optional[np.ndarray] = None):
+        """Validate the data before clustering.
 
-    def validate_data(
-            self,
-            X: list[np.ndarray],
-            y: Optional[np.ndarray] = None
-    ):
-        """
-        validates the data prior to clustering
+        Parameters
+        ----------
+        X : list of np.ndarray
+            The input data sets for each module.
+        y : np.ndarray, optional
+            The corresponding labels, by default None.
 
-        Parameters:
-        - X: list of deep data sets
-        - y: optional labels for data
+        Raises
+        ------
+        AssertionError
+            If the input data is inconsistent or does not match the expected format.
 
         """
-        assert len(X) == self.n_modules, \
-            f"Must provide {self.n_modules} input matrices for {self.n_modules} ART modules"
+        assert len(X) == self.n_modules, (
+            f"Must provide {self.n_modules} input matrices for "
+            f"{self.n_modules} ART modules"
+        )
         if y is not None:
             n = len(y)
         else:
             n = X[0].shape[0]
-        assert all(x.shape[0] == n for x in X), "Inconsistent sample number in input matrices"
-
-    def prepare_data(self,  X: list[np.ndarray], y: Optional[np.ndarray] = None) ->Tuple[list[np.ndarray], Optional[np.ndarray]]:
-        """
-        prepare data for clustering
+        assert all(
+            x.shape[0] == n for x in X
+        ), "Inconsistent sample number in input matrices"
+
+    def prepare_data(
+        self, X: Union[np.ndarray, list[np.ndarray]], y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[list[np.ndarray], Optional[np.ndarray]]]:
+        """Prepare the data for clustering.
+
+        Parameters
+        ----------
+        X : list of np.ndarray
+            The input data set for each module.
+        y : np.ndarray, optional
+            The corresponding labels, by default None.
+
+        Returns
+        -------
+        tuple of (list of np.ndarray, np.ndarray)
+            The prepared data set and labels (if any).
 
-        Parameters:
-        - X: data set
-
-        Returns:
-            prepared data
         """
         return [self.modules[i].prepare_data(X[i]) for i in range(self.n_modules)], y
 
-    def restore_data(self,  X: list[np.ndarray], y: Optional[np.ndarray] = None) ->Tuple[list[np.ndarray], Optional[np.ndarray]]:
-        """
-        restore data to state prior to preparation
+    def restore_data(
+        self, X: Union[np.ndarray, list[np.ndarray]], y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[list[np.ndarray], Optional[np.ndarray]]]:
+        """Restore the data to its original state before preparation.
+
+        Parameters
+        ----------
+        X : list of np.ndarray
+            The input data set for each module.
+        y : np.ndarray, optional
+            The corresponding labels, by default None.
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        tuple of (list of np.ndarray, np.ndarray)
+            The restored data set and labels (if any).
 
-        Returns:
-            prepared data
         """
         return [self.modules[i].restore_data(X[i]) for i in range(self.n_modules)], y
 
-
-    def fit(self, X: list[np.ndarray], y: Optional[np.ndarray] = None, max_iter=1, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: list of deep datasets
-        - y: optional labels
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: list[np.ndarray],
+        y: Optional[np.ndarray] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Fit the DeepARTMAP model to the data.
+
+        Parameters
+        ----------
+        X : list of np.ndarray
+            The input data sets for each module.
+        y : np.ndarray, optional
+            The corresponding labels for supervised learning, by default None.
+        max_iter : int, optional
+            The number of iterations to fit the model, by default 1.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            The method to reset vigilance if a mismatch occurs, by default "MT+".
+        epsilon : float, optional
+            A small adjustment factor for match tracking, by default 0.0.
+
+        Returns
+        -------
+        DeepARTMAP
+            The fitted DeepARTMAP model.
 
         """
         self.validate_data(X, y)
         if y is not None:
             self.is_supervised = True
             self.layers = [SimpleARTMAP(self.modules[i]) for i in range(self.n_modules)]
-            self.layers[0] = self.layers[0].fit(X[0], y, max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+            self.layers[0] = self.layers[0].fit(
+                X[0],
+                y,
+                max_iter=max_iter,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
         else:
             self.is_supervised = False
-            assert self.n_modules >= 2, "Must provide at least two ART modules when providing cluster labels"
-            self.layers = cast(list[BaseARTMAP], [ARTMAP(self.modules[1], self.modules[0])]) + \
-                           cast(list[BaseARTMAP], [SimpleARTMAP(self.modules[i]) for i in range(2, self.n_modules)])
-            self.layers[0] = self.layers[0].fit(X[1], X[0], max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+            assert (
+                self.n_modules >= 2
+            ), "Must provide at least two ART modules when providing cluster labels"
+            self.layers = cast(
+                list[BaseARTMAP], [ARTMAP(self.modules[1], self.modules[0])]
+            ) + cast(
+                list[BaseARTMAP],
+                [SimpleARTMAP(self.modules[i]) for i in range(2, self.n_modules)],
+            )
+            self.layers[0] = self.layers[0].fit(
+                X[1],
+                X[0],
+                max_iter=max_iter,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
 
         for art_i in range(1, self.n_layers):
-            y_i = self.layers[art_i-1].labels_a
-            self.layers[art_i] = self.layers[art_i].fit(X[art_i], y_i, max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+            y_i = self.layers[art_i - 1].labels_a
+            self.layers[art_i] = self.layers[art_i].fit(
+                X[art_i],
+                y_i,
+                max_iter=max_iter,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
 
         return self
 
-
-    def partial_fit(self, X: list[np.ndarray], y: Optional[np.ndarray] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        Partial fit the model to the data
-
-        Parameters:
-        - X: list of deep datasets
-        - y: optional labels
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def partial_fit(
+        self,
+        X: list[np.ndarray],
+        y: Optional[np.ndarray] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Partially fit the DeepARTMAP model to the data.
+
+        Parameters
+        ----------
+        X : list of np.ndarray
+            The input data sets for each module.
+        y : np.ndarray, optional
+            The corresponding labels for supervised learning, by default None.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            The method to reset vigilance if a mismatch occurs, by default "MT+".
+        epsilon : float, optional
+            A small adjustment factor for match tracking, by default 0.0.
+
+        Returns
+        -------
+        DeepARTMAP
+            The partially fitted DeepARTMAP model.
 
         """
         self.validate_data(X, y)
         if y is not None:
             if len(self.layers) == 0:
                 self.is_supervised = True
-                self.layers = [SimpleARTMAP(self.modules[i]) for i in range(self.n_modules)]
-            assert self.is_supervised, "Labels were previously provided. Must continue to provide labels for partial fit."
-            self.layers[0] = self.layers[0].partial_fit(X[0], y, match_reset_method=match_reset_method, epsilon=epsilon)
+                self.layers = [
+                    SimpleARTMAP(self.modules[i]) for i in range(self.n_modules)
+                ]
+            assert self.is_supervised, (
+                "Labels were previously provided. "
+                "Must continue to provide labels for partial fit."
+            )
+            self.layers[0] = self.layers[0].partial_fit(
+                X[0], y, match_tracking=match_tracking, epsilon=epsilon
+            )
             x_i = 1
         else:
             if len(self.layers) == 0:
                 self.is_supervised = False
-                assert self.n_modules >= 2, "Must provide at least two ART modules when providing cluster labels"
-                self.layers = cast(list[BaseARTMAP], [ARTMAP(self.modules[1], self.modules[0])]) + \
-                               cast(list[BaseARTMAP], [SimpleARTMAP(self.modules[i]) for i in range(2, self.n_modules)])
-            assert not self.is_supervised, "Labels were not previously provided. Do not provide labels to continue partial fit."
-            self.layers[0] = self.layers[0].partial_fit(X[1], X[0], match_reset_method=match_reset_method, epsilon=epsilon)
+                assert (
+                    self.n_modules >= 2
+                ), "Must provide at least two ART modules when providing cluster labels"
+                self.layers = cast(
+                    list[BaseARTMAP], [ARTMAP(self.modules[1], self.modules[0])]
+                ) + cast(
+                    list[BaseARTMAP],
+                    [SimpleARTMAP(self.modules[i]) for i in range(2, self.n_modules)],
+                )
+            assert not self.is_supervised, (
+                "Labels were not previously provided. "
+                "Do not provide labels to continue partial fit."
+            )
+
+            self.layers[0] = self.layers[0].partial_fit(
+                X[1],
+                X[0],
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
             x_i = 2
 
         n_samples = X[0].shape[0]
-
-        for art_i in range(1, self.n_modules):
-            y_i = self.layers[art_i-1].labels_a[-n_samples:]
-            self.layers[art_i] = self.layers[art_i].partial_fit(X[x_i], y_i, match_reset_method=match_reset_method, epsilon=epsilon)
+        for art_i in range(1, self.n_layers):
+            y_i = self.layers[art_i - 1].labels_a[-n_samples:]
+            self.layers[art_i] = self.layers[art_i].partial_fit(
+                X[x_i],
+                y_i,
+                match_tracking=match_tracking,
+                epsilon=epsilon,
+            )
             x_i += 1
         return self
 
-
     def predict(self, X: Union[np.ndarray, list[np.ndarray]]) -> list[np.ndarray]:
-        """
-        predict labels for the data
+        """Predict the labels for the input data.
 
-        Parameters:
-        - X: list of deep data sets
+        Parameters
+        ----------
+        X : np.ndarray or list of np.ndarray
+            The input data set for prediction.
 
-        Returns:
-            B labels for the data
+        Returns
+        -------
+        list of np.ndarray
+            The predicted labels for each layer.
 
         """
         if isinstance(X, list):
@@ -272,8 +434,3 @@ def predict(self, X: Union[np.ndarray, list[np.ndarray]]) -> list[np.ndarray]:
             pred.append(layer.map_a2b(pred[-1]))
 
         return pred[::-1]
-
-
-
-
-
diff --git a/artlib/hierarchical/SMART.py b/artlib/hierarchical/SMART.py
index a820618..8c2d845 100644
--- a/artlib/hierarchical/SMART.py
+++ b/artlib/hierarchical/SMART.py
@@ -1,94 +1,200 @@
-"""
-Bartfai, G. (1994).
-Hierarchical clustering with ART neural networks.
-In Proc. IEEE International Conference on Neural Networks (ICNN)
-(pp. 940–944). volume 2. doi:10.1109/ICNN.1994.374307.
-"""
+"""SMART :cite:`bartfai1994hierarchical`."""
+# Bartfai, G. (1994).
+# Hierarchical clustering with ART neural networks.
+# In Proc. IEEE International Conference on Neural Networks (ICNN)
+# (pp. 940–944). volume 2.
+# doi:10.1109/ICNN.1994.374307.
 
 import numpy as np
-from typing import Union, Type, Optional, Iterable, Literal
+from typing import Union, Type, Optional, Literal, Tuple
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
+from artlib.common.utils import IndexableOrKeyable
 from artlib.hierarchical.DeepARTMAP import DeepARTMAP
 
+
 class SMART(DeepARTMAP):
+    """SMART for Hierachical Clustering.
 
-    def __init__(self, base_ART_class: Type, rho_values: Union[list[float], np.ndarray], base_params: dict, **kwargs):
-        """
+    This module implements SMART as first published in: :cite:`bartfai1994hierarchical`
 
-        Parameters:
-        - base_ART: some ART class
-        - rho_values: rho parameters for each sub-module
-        - base_params: base param dict for each sub-module
+    .. # Bartfai, G. (1994).
+    .. # Hierarchical clustering with ART neural networks.
+    .. # In Proc. IEEE International Conference on Neural Networks (ICNN)
+    .. # (pp. 940–944). volume 2.
+    .. # doi:10.1109/ICNN.1994.374307.
 
-        """
+    SMART accepts an uninstantiated :class:`~artlib.common.BaseART.BaseART` class and
+    hierarchically clusters data in a divisive fashion by using a set of vigilance
+    values that monotonically increase in their restrictiveness. SMART is a special
+    case of :class:`~artlib.hierarchical.DeepARTMAP.DeepARTMAP`, which forms the
+    backbone of this class, where all channels receive the same data.
+
+    """
 
-        assert all(np.diff(rho_values) > 0), "rho_values must be monotonically increasing"
+    def __init__(
+        self,
+        base_ART_class: Type,
+        rho_values: Union[list[float], np.ndarray],
+        base_params: dict,
+        **kwargs
+    ):
+        """Initialize the SMART model.
+
+        Parameters
+        ----------
+        base_ART_class : Type
+            Some ART class to instantiate the layers.
+        rho_values : list of float or np.ndarray
+            The vigilance parameter values for each layer, must be monotonically
+            increasing for most ART modules.
+        base_params : dict
+            Parameters for the base ART module, used to instantiate each layer.
+        **kwargs :
+            Additional keyword arguments for ART module initialization.
+
+        """
+        if base_ART_class.__name__ != "BayesianART":
+            assert all(
+                np.diff(rho_values) > 0
+            ), "rho_values must be monotonically increasing"
+        else:
+            assert all(
+                np.diff(rho_values) < 0
+            ), "rho_values must be monotonically decreasing for BayesianART"
         self.rho_values = rho_values
 
         layer_params = [dict(base_params, **{"rho": rho}) for rho in self.rho_values]
-        layers = [base_ART_class(**params, **kwargs) for params in layer_params]
-        for layer in layers:
-            assert isinstance(layer, BaseART), "Only elementary ART-like objects are supported"
-        super().__init__(layers)
+        modules = [base_ART_class(**params, **kwargs) for params in layer_params]
+        for module in modules:
+            assert isinstance(
+                module, BaseART
+            ), "Only elementary ART-like objects are supported"
+        super().__init__(modules)
 
-    def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+    def prepare_data(
+        self, X: Union[np.ndarray, list[np.ndarray]], y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[list[np.ndarray], Optional[np.ndarray]]]:
+        """Prepare data for clustering.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to prepare.
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        np.ndarray
+            Prepared data.
 
-        Returns:
-            prepared data
         """
-        X_, _ = super(SMART, self).prepare_data([X]*self.n_modules)
+        X_, _ = super(SMART, self).prepare_data([X] * self.n_modules)
         return X_[0]
 
-    def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+    def restore_data(
+        self, X: Union[np.ndarray, list[np.ndarray]], y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[list[np.ndarray], Optional[np.ndarray]]]:
+        """Restore data to its original form before preparation.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to restore.
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-        Returns:
-            restored data
         """
         X_, _ = super(SMART, self).restore_data([X] * self.n_modules)
         return X_[0]
 
-    def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, max_iter=1, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: not used
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Fit the SMART model to the data.
 
-        """
-        X_list = [X]*self.n_modules
-        return super().fit(X_list, max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to fit the model on.
+        y : np.ndarray, optional
+            Not used, present for compatibility.
+        max_iter : int, optional
+            The number of iterations to run the model on the data.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            The match reset method to use when adjusting vigilance.
+        epsilon : float, optional
+            A small value to adjust vigilance during match tracking.
 
-    def partial_fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0):
+        Returns
+        -------
+        SMART
+            Fitted SMART model.
+
+        """
         X_list = [X] * self.n_modules
-        return self.partial_fit(X_list, match_reset_method=match_reset_method, epsilon=epsilon)
+        return super().fit(
+            X_list,
+            max_iter=max_iter,
+            match_tracking=match_tracking,
+            epsilon=epsilon,
+        )
+
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        y: Optional[np.ndarray] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ):
+        """Partial fit the SMART model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to partially fit the model on.
+        y : np.ndarray, optional
+            Not used, present for compatibility.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            The match reset method to use when adjusting vigilance.
+        epsilon : float, optional
+            A small value to adjust vigilance during match tracking.
+
+        Returns
+        -------
+        SMART
+            Partially fitted SMART model.
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
         """
-        undefined function for visualizing the bounds of each cluster
+        X_list = [X] * self.n_modules
+        return super(SMART, self).partial_fit(
+            X_list, match_tracking=match_tracking, epsilon=epsilon
+        )
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the cluster boundaries.
+
+        Parameters
+        ----------
+        ax : Axes
+            The matplotlib axes on which to plot the cluster boundaries.
+        colors : IndexableOrKeyable
+            The colors to use for each cluster.
+        linewidth : int, optional
+            The width of the boundary lines.
+
+        Returns
+        -------
+        None
 
         """
         for j in range(len(self.modules)):
@@ -100,26 +206,35 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
                     layer_colors.append(colors[self.map_deep(j - 1, k)])
             self.modules[j].plot_cluster_bounds(ax, layer_colors, linewidth)
 
-
     def visualize(
-            self,
-            X: np.ndarray,
-            y: np.ndarray,
-            ax: Optional[Axes] = None,
-            marker_size: int = 10,
-            linewidth: int = 1,
-            colors: Optional[Iterable] = None
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        ax: Optional[Axes] = None,
+        marker_size: int = 10,
+        linewidth: int = 1,
+        colors: Optional[IndexableOrKeyable] = None,
     ):
-        """
-        Visualize the clustering of the data
+        """Visualize the clustering of the data with cluster boundaries.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset to visualize.
+        y : np.ndarray
+            The cluster labels for the data points.
+        ax : Axes, optional
+            The matplotlib axes on which to plot the visualization.
+        marker_size : int, optional
+            The size of the data points in the plot.
+        linewidth : int, optional
+            The width of the cluster boundary lines.
+        colors : IndexableOrKeyable, optional
+            The colors to use for each cluster.
 
-        Parameters:
-        - X: data set
-        - y: sample labels
-        - ax: figure axes
-        - marker_size: size used for data points
-        - linewidth: width of boundary line
-        - colors: colors to use for each cluster
+        Returns
+        -------
+        None
 
         """
         import matplotlib.pyplot as plt
@@ -129,10 +244,17 @@ def visualize(
 
         if colors is None:
             from matplotlib.pyplot import cm
+
             colors = cm.rainbow(np.linspace(0, 1, self.modules[0].n_clusters))
 
         for k, col in enumerate(colors):
             cluster_data = y == k
-            plt.scatter(X[cluster_data, 0], X[cluster_data, 1], color=col, marker=".", s=marker_size)
+            plt.scatter(
+                X[cluster_data, 0],
+                X[cluster_data, 1],
+                color=col,
+                marker=".",
+                s=marker_size,
+            )
 
-        self.plot_cluster_bounds(ax, colors, linewidth)
\ No newline at end of file
+        self.plot_cluster_bounds(ax, colors, linewidth)
diff --git a/artlib/hierarchical/__init__.py b/artlib/hierarchical/__init__.py
index e69de29..32264ef 100644
--- a/artlib/hierarchical/__init__.py
+++ b/artlib/hierarchical/__init__.py
@@ -0,0 +1,12 @@
+"""Hierarchical clustering is a method of cluster analysis that seeks to build a
+hierarchy of clusters. In divisive clustering, also known as top-down clustering, the
+process starts with all data points in a single cluster and recursively splits it into
+smaller clusters. This contrasts with agglomerative (bottom-up) clustering, which starts
+with individual points and merges them. Divisive clustering is useful when trying to
+divide data into broad categories before refining into finer subcategories.
+
+The modules herein are exclusively divisive clustering approaches for ART
+
+`Divisive clustering <https://en.wikipedia.org/wiki/Hierarchical_clustering#Divisive>`_
+
+"""
diff --git a/artlib/reinforcement/FALCON.py b/artlib/reinforcement/FALCON.py
index 389db87..8946ffb 100644
--- a/artlib/reinforcement/FALCON.py
+++ b/artlib/reinforcement/FALCON.py
@@ -1,49 +1,223 @@
+"""FALCON :cite:`tan2004falcon`, :cite:`tan2008integrating`."""
+# Tan, A.-H. (2004).
+# FALCON: a fusion architecture for learning, cognition, and navigation.
+# In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+# (pp. 3297–3302). volume 4. doi:10.1109/ IJCNN.2004.1381208.
+
+# Tan, A.-H., Lu, N., & Xiao, D. (2008).
+# Integrating Temporal Difference Methods and Self-Organizing Neural Networks for
+# Reinforcement Learning With Delayed Evaluative Feedback.
+# IEEE Transactions on Neural Networks, 19 , 230–244. doi:10.1109/TNN.2007.905839
+
 import numpy as np
-from typing import Optional, Literal
-from artlib import FusionART, BaseART, compliment_code, de_compliment_code
+from typing import Optional, Literal, Tuple, Union, List
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import compliment_code, de_compliment_code
+from artlib.fusion.FusionART import FusionART
 
 
 class FALCON:
+    """FALCON for Reinforcement Learning.
+
+    This module implements the reactive FALCON as first described in:
+    :cite:`tan2004falcon`.
+
+    .. # Tan, A.-H. (2004).
+    .. # FALCON: a fusion architecture for learning, cognition, and navigation.
+    .. # In Proc. IEEE International Joint Conference on Neural Networks (IJCNN)
+    .. # (pp. 3297–3302). volume 4. doi:10.1109/ IJCNN.2004.1381208.
+
+    FALCON is based on a :class:`~artlib.fusion.FusionART.FusionART` backbone but only
+    accepts 3 channels: State, Action, and Reward. Specific functions are implemented
+    for getting optimal reward and action predictions.
+
+    """
+
     def __init__(
-            self,
-            state_art: BaseART,
-            action_art: BaseART,
-            reward_art: BaseART,
-            gamma_values: list[float] = np.array([0.33, 0.33, 0.34]),
-            channel_dims = list[int]
+        self,
+        state_art: BaseART,
+        action_art: BaseART,
+        reward_art: BaseART,
+        gamma_values: Union[List[float], np.ndarray] = np.array([0.33, 0.33, 0.34]),
+        channel_dims: Union[List[int], np.ndarray] = list[int],
     ):
+        """Initialize the FALCON model.
+
+        Parameters
+        ----------
+        state_art : BaseART
+            The instantiated ART module that will cluster the state-space.
+        action_art : BaseART
+            The instantiated ART module that will cluster the action-space.
+        reward_art : BaseART
+            The instantiated ART module that will cluster the reward-space.
+        gamma_values : list of float or np.ndarray, optional
+            The activation ratio for each channel, by default [0.33, 0.33, 0.34].
+        channel_dims : list of int or np.ndarray
+            The dimension of each channel.
+
+        """
         self.fusion_art = FusionART(
             modules=[state_art, action_art, reward_art],
             gamma_values=gamma_values,
-            channel_dims=channel_dims
+            channel_dims=channel_dims,
+        )
+
+    def prepare_data(
+        self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray
+    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+        """Prepare data for clustering.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+
+        Returns
+        -------
+        tuple of np.ndarray
+            Normalized state, action, and reward data.
+
+        """
+        return (
+            self.fusion_art.modules[0].prepare_data(states),
+            self.fusion_art.modules[1].prepare_data(actions),
+            self.fusion_art.modules[2].prepare_data(rewards),
+        )
+
+    def restore_data(
+        self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray
+    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+        """Restore data to its original form before preparation.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+
+        Returns
+        -------
+        tuple of np.ndarray
+            Restored state, action, and reward data.
+
+        """
+        return (
+            self.fusion_art.modules[0].restore_data(states),
+            self.fusion_art.modules[1].restore_data(actions),
+            self.fusion_art.modules[2].restore_data(rewards),
         )
 
     def fit(self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray):
+        """Fit the FALCON model to the data.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+
+        Returns
+        -------
+        FALCON
+            The fitted FALCON model.
+
+        """
         data = self.fusion_art.join_channel_data([states, actions, rewards])
         self.fusion_art = self.fusion_art.fit(data)
         return self
 
     def partial_fit(self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray):
+        """Partially fit the FALCON model to the data.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+
+        Returns
+        -------
+        FALCON
+            The partially fitted FALCON model.
+
+        """
         data = self.fusion_art.join_channel_data([states, actions, rewards])
         self.fusion_art = self.fusion_art.partial_fit(data)
         return self
 
-    def get_actions_and_rewards(self, state: np.ndarray, action_space: Optional[np.ndarray] = None) -> np.ndarray:
+    def get_actions_and_rewards(
+        self, state: np.ndarray, action_space: Optional[np.ndarray] = None
+    ) -> Tuple[np.ndarray, np.ndarray]:
+        """Get possible actions and their associated rewards for a given state.
+
+        Parameters
+        ----------
+        state : np.ndarray
+            The current state.
+        action_space : np.ndarray, optional
+            The available action space, by default None.
+
+        Returns
+        -------
+        tuple of np.ndarray
+            The possible actions and their corresponding rewards.
+
+        """
         reward_centers = self.fusion_art.get_channel_centers(2)
         if action_space is None:
             action_space = self.fusion_art.get_channel_centers(1)
             action_space = np.array(action_space)
-        action_space_prepared = self.fusion_art.modules[0].prepare_data(action_space)
+        action_space_prepared = self.fusion_art.modules[1].prepare_data(action_space)
         viable_clusters = []
         for action in action_space_prepared:
-            data = self.fusion_art.join_channel_data([state.reshape(1, -1), action.reshape(1, -1)], skip_channels=[2])
+            data = self.fusion_art.join_channel_data(
+                [state.reshape(1, -1), action.reshape(1, -1)], skip_channels=[2]
+            )
             c = self.fusion_art.predict(data, skip_channels=[2])
             viable_clusters.append(c[0])
 
         rewards = [reward_centers[c] for c in viable_clusters]
 
         return action_space, np.array(rewards)
-    def get_action(self, state: np.ndarray, action_space: Optional[np.ndarray] = None, optimality: Literal["min", "max"] = "max") -> np.ndarray:
+
+    def get_action(
+        self,
+        state: np.ndarray,
+        action_space: Optional[np.ndarray] = None,
+        optimality: Literal["min", "max"] = "max",
+    ) -> np.ndarray:
+        """Get the best action for a given state based on optimality.
+
+        Parameters
+        ----------
+        state : np.ndarray
+            The current state.
+        action_space : np.ndarray, optional
+            The available action space, by default None.
+        optimality : {"min", "max"}, optional
+            Whether to choose the action with the minimum or maximum reward,
+            by default "max".
+
+        Returns
+        -------
+        np.ndarray
+            The optimal action.
+
+        """
         action_space, rewards = self.get_actions_and_rewards(state, action_space)
         if optimality == "max":
             c_winner = np.argmax(rewards)
@@ -51,17 +225,41 @@ def get_action(self, state: np.ndarray, action_space: Optional[np.ndarray] = Non
             c_winner = np.argmin(rewards)
         return action_space[c_winner]
 
-    def get_probabilistic_action(self, state: np.ndarray, action_space: Optional[np.ndarray] = None, offset: float = 0.1, optimality: Literal["min", "max"] = "max") -> np.ndarray:
+    def get_probabilistic_action(
+        self,
+        state: np.ndarray,
+        action_space: Optional[np.ndarray] = None,
+        offset: float = 0.1,
+        optimality: Literal["min", "max"] = "max",
+    ) -> np.ndarray:
+        """Get a probabilistic action for a given state based on reward distribution.
+
+        Parameters
+        ----------
+        state : np.ndarray
+            The current state.
+        action_space : np.ndarray, optional
+            The available action space, by default None.
+        offset : float, optional
+            The reward offset to adjust probability distribution, by default 0.1.
+        optimality : {"min", "max"}, optional
+            Whether to prefer minimum or maximum rewards, by default "max".
+
+        Returns
+        -------
+        np.ndarray
+            The chosen action based on probability.
+
+        """
         action_space, rewards = self.get_actions_and_rewards(state, action_space)
         action_indices = np.array(range(len(action_space)))
 
-
         reward_dist = rewards
         reward_dist /= np.sum(reward_dist)
         reward_dist = reward_dist.reshape((-1,))
 
         if optimality == "min":
-            reward_dist = 1.-reward_dist
+            reward_dist = 1.0 - reward_dist
 
         reward_dist = np.maximum(np.minimum(reward_dist, offset), 0.0001)
         reward_dist /= np.sum(reward_dist)
@@ -70,39 +268,120 @@ def get_probabilistic_action(self, state: np.ndarray, action_space: Optional[np.
         return action_space[a_i[0]][0]
 
     def get_rewards(self, states: np.ndarray, actions: np.ndarray) -> np.ndarray:
+        """Get the rewards for given states and actions.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+
+        Returns
+        -------
+        np.ndarray
+            The rewards corresponding to the given state-action pairs.
+
+        """
         reward_centers = self.fusion_art.get_channel_centers(2)
         data = self.fusion_art.join_channel_data([states, actions], skip_channels=[2])
         C = self.fusion_art.predict(data, skip_channels=[2])
         return np.array([reward_centers[c] for c in C])
 
 
+class TD_FALCON(FALCON):
+    """TD-FALCON for Reinforcement Learning.
 
+    This module implements TD-FALCON as first described in:
+    :cite:`tan2008integrating`.
 
-class TD_FALCON(FALCON):
-    # implements SARSA learning
+    .. # Tan, A.-H., Lu, N., & Xiao, D. (2008).
+    .. # Integrating Temporal Difference Methods and Self-Organizing Neural Networks for
+    .. # Reinforcement Learning With Delayed Evaluative Feedback.
+    .. # IEEE Transactions on Neural Networks, 19 , 230–244. doi:10.1109/TNN.2007.905839
+
+    TD-FALCON is based on a :class:`FALCON` backbone but includes specific function for
+    temporal-difference learning. Currently, only SARSA is implemented and only
+    :class:`~artlib.elementary.FuzzyART.FuzzyART` base modules are supported.
+
+    """
 
     def __init__(
-            self,
-            state_art: BaseART,
-            action_art: BaseART,
-            reward_art: BaseART,
-            gamma_values: list[float] = np.array([0.33, 0.33, 0.34]),
-            channel_dims = list[int],
-            td_alpha: float = 1.0,
-            td_lambda: float = 1.0,
+        self,
+        state_art: BaseART,
+        action_art: BaseART,
+        reward_art: BaseART,
+        gamma_values: Union[List[float], np.ndarray] = np.array([0.33, 0.33, 0.34]),
+        channel_dims: Union[List[int], np.ndarray] = list[int],
+        td_alpha: float = 1.0,
+        td_lambda: float = 1.0,
     ):
+        """Initialize the TD-FALCON model.
+
+        Parameters
+        ----------
+        state_art : BaseART
+            The instantiated ART module that will cluster the state-space.
+        action_art : BaseART
+            The instantiated ART module that will cluster the action-space.
+        reward_art : BaseART
+            The instantiated ART module that will cluster the reward-space.
+        gamma_values : list of float or np.ndarray, optional
+            The activation ratio for each channel, by default [0.33, 0.33, 0.34].
+        channel_dims : list of int or np.ndarray
+            The dimension of each channel.
+        td_alpha : float, optional
+            The learning rate for the temporal difference estimator, by default 1.0.
+        td_lambda : float, optional
+            The future-cost factor for temporal difference learning, by default 1.0.
+
+        """
         self.td_alpha = td_alpha
         self.td_lambda = td_lambda
-        super(TD_FALCON, self).__init__(state_art, action_art, reward_art, gamma_values, channel_dims)
+        super(TD_FALCON, self).__init__(
+            state_art, action_art, reward_art, gamma_values, channel_dims
+        )
 
     def fit(self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray):
+        """Fit the TD-FALCON model to the data.
+
+        Raises
+        ------
+        NotImplementedError
+            TD-FALCON can only be trained with partial fit.
+
+        """
         raise NotImplementedError("TD-FALCON can only be trained with partial fit")
 
-    def calculate_SARSA(self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray, single_sample_reward: Optional[float] = None):
+    def calculate_SARSA(
+        self,
+        states: np.ndarray,
+        actions: np.ndarray,
+        rewards: np.ndarray,
+        single_sample_reward: Optional[float] = None,
+    ):
+        """Calculate the SARSA values for reinforcement learning.
+
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+        single_sample_reward : float, optional
+            The reward for a single sample, if applicable, by default None.
+
+        Returns
+        -------
+        tuple of np.ndarray
+            The state, action, and SARSA-adjusted reward data to be used for fitting.
+
+        """
         # calculate SARSA values
         rewards_dcc = de_compliment_code(rewards)
         if len(states) > 1:
-
             if hasattr(self.fusion_art.modules[0], "W"):
                 # if FALCON has been trained get predicted rewards
                 Q = self.get_rewards(states, actions)
@@ -110,7 +389,9 @@ def calculate_SARSA(self, states: np.ndarray, actions: np.ndarray, rewards: np.n
                 # otherwise set predicted rewards to 0
                 Q = np.zeros_like(rewards_dcc)
             # SARSA equation
-            sarsa_rewards = Q[:-1] + self.td_alpha * (rewards_dcc[:-1] + self.td_lambda * Q[1:] - Q[:-1])
+            sarsa_rewards = Q[:-1] + self.td_alpha * (
+                rewards_dcc[:-1] + self.td_lambda * Q[1:] - Q[:-1]
+            )
             # ensure SARSA values are between 0 and 1
             sarsa_rewards = np.maximum(np.minimum(sarsa_rewards, 1.0), 0.0)
             # compliment code rewards
@@ -129,13 +410,37 @@ def calculate_SARSA(self, states: np.ndarray, actions: np.ndarray, rewards: np.n
 
         return states_fit, actions_fit, sarsa_rewards_fit
 
-    def partial_fit(self, states: np.ndarray, actions: np.ndarray, rewards: np.ndarray, single_sample_reward: Optional[float] = None):
-
-        states_fit, actions_fit, sarsa_rewards_fit = self.calculate_SARSA(states, actions, rewards, single_sample_reward)
-        data = self.fusion_art.join_channel_data([states_fit, actions_fit, sarsa_rewards_fit])
-        self.fusion_art = self.fusion_art.partial_fit(data)
-        return self
-
+    def partial_fit(
+        self,
+        states: np.ndarray,
+        actions: np.ndarray,
+        rewards: np.ndarray,
+        single_sample_reward: Optional[float] = None,
+    ):
+        """Partially fit the TD-FALCON model using SARSA.
 
+        Parameters
+        ----------
+        states : np.ndarray
+            The state data.
+        actions : np.ndarray
+            The action data.
+        rewards : np.ndarray
+            The reward data.
+        single_sample_reward : float, optional
+            The reward for a single sample, if applicable, by default None.
 
+        Returns
+        -------
+        TD_FALCON
+            The partially fitted TD-FALCON model.
 
+        """
+        states_fit, actions_fit, sarsa_rewards_fit = self.calculate_SARSA(
+            states, actions, rewards, single_sample_reward
+        )
+        data = self.fusion_art.join_channel_data(
+            [states_fit, actions_fit, sarsa_rewards_fit]
+        )
+        self.fusion_art = self.fusion_art.partial_fit(data)
+        return self
diff --git a/artlib/reinforcement/__init__.py b/artlib/reinforcement/__init__.py
new file mode 100644
index 0000000..fb10e97
--- /dev/null
+++ b/artlib/reinforcement/__init__.py
@@ -0,0 +1,19 @@
+"""Reinforcement learning (RL) is a type of machine learning where agents learn to make
+decisions by interacting with an environment and receiving feedback in the form of
+rewards or penalties. The SARSA (State-Action-Reward-State-Action) algorithm is an on-
+policy RL method that updates the agent’s policy based on the action actually taken.
+This contrasts with Q-learning, which is off-policy and learns the optimal action
+independently of the agent’s current policy.
+
+Reactive learning, on the other hand, is a more straightforward approach where decisions
+are made solely based on immediate observations, without the complex state-action-reward
+feedback loop typical of RL models like SARSA or Q-learning. It lacks the depth of
+planning and long-term reward optimization seen in traditional RL.
+
+The modules herein only provide for reactive and SARSA style learning.
+
+`SARSA <https://en.wikipedia.org/wiki/Reinforcement_learning#SARSA>`_
+
+`Reactive agents <https://en.wikipedia.org/wiki/Reactive_planning>`_
+
+"""
diff --git a/artlib/supervised/ARTMAP.py b/artlib/supervised/ARTMAP.py
index 0e61810..a24a389 100644
--- a/artlib/supervised/ARTMAP.py
+++ b/artlib/supervised/ARTMAP.py
@@ -1,34 +1,63 @@
-"""
-Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
-ARTMAP: Supervised real-time learning and classification of nonstationary data by a self-organizing neural network.
-Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
-"""
+"""ARTMAP :cite:`carpenter1991artmap`."""
+# Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
+# ARTMAP: Supervised real-time learning and classification of nonstationary data by a
+# self-organizing neural network.
+# Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
 import numpy as np
-from typing import Literal, Tuple
+from typing import Literal, Tuple, Dict, Union, Optional
 from artlib.common.BaseART import BaseART
 from artlib.supervised.SimpleARTMAP import SimpleARTMAP
 from sklearn.utils.validation import check_is_fitted
 
 
 class ARTMAP(SimpleARTMAP):
+    """ARTMAP for Classification and Regression.
+
+    This module implements ARTMAP as first published in: :cite:`carpenter1991artmap`.
+
+    .. # Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1991a).
+    .. # ARTMAP: Supervised real-time learning and classification of nonstationary data
+    .. # by a self-organizing neural network.
+    .. # Neural Networks, 4, 565 – 588. doi:10.1016/0893-6080(91)90012-T.
+
+    ARTMAP accepts two :class:`~artlib.common.BaseART.BaseART` modules A and B which
+    cluster the dependent
+    channel (samples) and the independent channel (labels) respectively while linking
+    them with a many-to-one mapping. If your labels are integers,
+    use :class:`~artlib.supervised.SimpleARTMAP.SimpleARTMAP` for a faster and more
+    direct implementation. ARTMAP also provides the ability to fit a regression model to
+    data and specific functions have been implemented to allow this. However,
+    :class:`~artlib.fusion.FusionART.FusionART` provides substantially better fit for
+    regression problems which are not monotonic.
+
+    """
+
     def __init__(self, module_a: BaseART, module_b: BaseART):
-        """
+        """Initialize the ARTMAP model with two ART modules.
 
-        Parameters:
-        - module_a: a-side ART module
-        - module_b: b-side ART module
+        Parameters
+        ----------
+        module_a : BaseART
+            A-side ART module for clustering the independent channel.
+        module_b : BaseART
+            B-side ART module for clustering the dependent channel.
 
         """
         self.module_b = module_b
         super(ARTMAP, self).__init__(module_a)
 
     def get_params(self, deep: bool = True) -> dict:
-        """
+        """Get the parameters of the ARTMAP model.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+        Parameters
+        ----------
+        deep : bool, optional
+            If True, will return the parameters for this class and contained subobjects
+            that are estimators.
 
-        Returns:
+        Returns
+        -------
+        dict
             Parameter names mapped to their values.
 
         """
@@ -45,127 +74,218 @@ def get_params(self, deep: bool = True) -> dict:
             out.update(("module_b" + "__" + k, val) for k, val in deep_b_items)
         return out
 
-
     @property
-    def labels_a(self):
+    def labels_a(self) -> np.ndarray:
+        """Get the labels generated by the A-side ART module.
+
+        Returns
+        -------
+        np.ndarray
+            Labels for the A-side data (independent channel).
+
+        """
         return self.module_a.labels_
 
     @property
-    def labels_b(self):
-        return self.labels_
+    def labels_b(self) -> np.ndarray:
+        """Get the labels generated by the B-side ART module.
+
+        Returns
+        -------
+        np.ndarray
+            Labels for the B-side data (dependent channel).
+
+        """
+        return self.module_b.labels_
 
     @property
-    def labels_ab(self):
-        return {"A": self.labels_a, "B": self.labels_}
+    def labels_ab(self) -> Dict[str, np.ndarray]:
+        """Get the labels generated by both the A-side and B-side ART modules.
+
+        Returns
+        -------
+        dict
+            Dictionary containing both A-side and B-side labels.
 
-    def validate_data(self, X: np.ndarray, y: np.ndarray):
         """
-        validates the data prior to clustering
+        return {"A": self.labels_a, "B": self.module_b.labels_}
 
-        Parameters:
-        - X: data set A
-        - y: data set B
+    def validate_data(self, X: np.ndarray, y: np.ndarray):
+        """Validate the input data prior to clustering.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+        y : np.ndarray
+            Data set B (dependent channel).
 
         """
         self.module_a.validate_data(X)
         self.module_b.validate_data(y)
 
-    def prepare_data(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
-        """
-        prepare data for clustering
+    def prepare_data(
+        self, X: np.ndarray, y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
+        """Prepare data for clustering by normalizing and transforming.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+        y : np.ndarray
+            Data set B (dependent channel).
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        tuple of np.ndarray
+            Normalized data for both channels.
 
-        Returns:
-            normalized data
         """
+        assert y is not None
         return self.module_a.prepare_data(X), self.module_b.prepare_data(y)
 
-    def restore_data(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
-        """
-        restore data to state prior to preparation
+    def restore_data(
+        self, X: np.ndarray, y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
+        """Restore data to its original state before preparation.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+        y : np.ndarray
+            Data set B (dependent channel).
 
-        Parameters:
-        - X: data set
+        Returns
+        -------
+        tuple of np.ndarray
+            Restored data for both channels.
 
-        Returns:
-            restored data
         """
+        assert y is not None
         return self.module_a.restore_data(X), self.module_b.restore_data(y)
 
-    def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: data set B
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+        verbose: bool = False,
+    ):
+        """Fit the ARTMAP model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+        y : np.ndarray
+            Data set B (dependent channel).
+        max_iter : int, optional
+            Number of iterations to fit the model on the same data set.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting the vigilance parameter when match criterion fails.
+        epsilon : float, optional
+            Small increment to modify the vigilance parameter.
+        verbose : bool, default=False
+            If True, displays a progress bar during training.
+
+        Returns
+        -------
+        self : ARTMAP
+            Fitted ARTMAP model.
 
         """
         # Check that X and y have correct shape
         self.validate_data(X, y)
 
-        self.module_b.fit(y, max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+        self.module_b.fit(
+            y,
+            max_iter=max_iter,
+            match_tracking=match_tracking,
+            epsilon=epsilon,
+        )
 
         y_c = self.module_b.labels_
 
-        super(ARTMAP, self).fit(X, y_c, max_iter=max_iter, match_reset_method=match_reset_method, epsilon=epsilon)
+        super(ARTMAP, self).fit(
+            X,
+            y_c,
+            max_iter=max_iter,
+            match_tracking=match_tracking,
+            epsilon=epsilon,
+            verbose=verbose,
+        )
 
         return self
 
-
-    def partial_fit(self, X: np.ndarray, y: np.ndarray, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10):
-        """
-        Partial fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: data set B
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+    ):
+        """Partially fit the ARTMAP model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+        y : np.ndarray
+            Data set B (dependent channel).
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting the vigilance parameter when match criterion fails.
+        epsilon : float, optional
+            Small increment to modify the vigilance parameter.
+
+        Returns
+        -------
+        self : ARTMAP
+            Partially fitted ARTMAP model.
 
         """
         self.validate_data(X, y)
-        self.module_b.partial_fit(y, match_reset_method=match_reset_method, epsilon=epsilon)
-        super(ARTMAP, self).partial_fit(X, self.labels_b, match_reset_method=match_reset_method, epsilon=epsilon)
+        self.module_b.partial_fit(y, match_tracking=match_tracking, epsilon=epsilon)
+        super(ARTMAP, self).partial_fit(
+            X,
+            self.labels_b,
+            match_tracking=match_tracking,
+            epsilon=epsilon,
+        )
         return self
 
-
     def predict(self, X: np.ndarray) -> np.ndarray:
-        """
-        predict labels for the data
+        """Predict the labels for the given data.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
 
-        Returns:
-            B labels for the data
+        Returns
+        -------
+        np.ndarray
+            Predicted labels for data set B (dependent channel).
 
         """
         check_is_fitted(self)
         return super(ARTMAP, self).predict(X)
 
     def predict_ab(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
-        """
-        predict labels for the data, both A-side and B-side
+        """Predict both A-side and B-side labels for the given data.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
 
-        Returns:
-            A labels for the data, B labels for the data
+        Returns
+        -------
+        tuple of np.ndarray
+            A labels and B labels for the data.
 
         """
         check_is_fitted(self)
@@ -173,15 +293,19 @@ def predict_ab(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
 
     def predict_regression(self, X: np.ndarray) -> np.ndarray:
         """
-        predict values for the data
-        ARTMAP is not recommended for regression. Use FusionART instead.
-
-        Parameters:
-        - X: data set A
-
-        Returns:
-            predicted values using cluster centers
-
+        Predict values for the given data using cluster centers.
+        Note: ARTMAP is not recommended for regression.
+        Use FusionART for regression tasks.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A (independent channel).
+
+        Returns
+        -------
+        np.ndarray
+            Predicted values using cluster centers.
         """
         check_is_fitted(self)
         C = self.predict(X)
diff --git a/artlib/supervised/SimpleARTMAP.py b/artlib/supervised/SimpleARTMAP.py
index b0bf4d9..34c59c9 100644
--- a/artlib/supervised/SimpleARTMAP.py
+++ b/artlib/supervised/SimpleARTMAP.py
@@ -1,41 +1,79 @@
-"""
-Serrano-Gotarredona, T., Linares-Barranco, B., & Andreou, A. G. (1998).
-Adaptive Resonance Theory Microchips: Circuit Design Techniques.
-Norwell, MA, USA: Kluwer Academic Publishers.
-"""
+"""Simple ARTMAP :cite:`gotarredona1998adaptive`."""
+# Serrano-Gotarredona, T., Linares-Barranco, B., & Andreou, A. G. (1998).
+# Adaptive Resonance Theory Microchips: Circuit Design Techniques.
+# Norwell, MA, USA: Kluwer Academic Publishers.
 import numpy as np
-from typing import Optional, Iterable, Literal
+from typing import Optional, Literal, Dict, Union, Tuple
 from matplotlib.axes import Axes
 from artlib.common.BaseART import BaseART
 from artlib.common.BaseARTMAP import BaseARTMAP
+from artlib.common.utils import IndexableOrKeyable
 from sklearn.utils.validation import check_is_fitted, check_X_y
 from sklearn.utils.multiclass import unique_labels
 
 
 class SimpleARTMAP(BaseARTMAP):
+    """SimpleARTMAP for Classification.
 
-    def match_reset_func(
-            self,
-            i: np.ndarray,
-            w: np.ndarray,
-            cluster_a,
-            params: dict,
-            extra: dict,
-            cache: Optional[dict] = None
-    ) -> bool:
-        """
-        Permits external factors to influence cluster creation.
+    This module implements SimpleARTMAP as first published in:
+    :cite:`gotarredona1998adaptive`.
+
+    .. # Serrano-Gotarredona, T., Linares-Barranco, B., & Andreou, A. G. (1998).
+    .. # Adaptive Resonance Theory Microchips: Circuit Design Techniques.
+    .. # Norwell, MA, USA: Kluwer Academic Publishers.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - cluster_a: a-side cluster label
-        - params: dict containing parameters for the algorithm
-        - extra: additional parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
+    SimpleARTMAP is a special case of :class:`~artlib.supervised.ARTMAP.ARTMAP`
+    specifically for classification. It allows the clustering of data samples while
+    enforcing a many-to-one mapping from sample clusters to labels. It accepts an
+    instantiated :class:`~artlib.common.BaseART.BaseART` module and dynamically adapts
+    the vigilance function to prevent resonance when the many-to-one mapping is
+    violated. This enables SimpleARTMAP to identify discrete clusters belonging to
+    each category label.
 
-        Returns:
-            true if match is permitted
+    """
+
+    def __init__(self, module_a: BaseART):
+        """Initialize SimpleARTMAP.
+
+        Parameters
+        ----------
+        module_a : BaseART
+            The instantiated ART module used for clustering the independent channel.
+
+        """
+        self.module_a = module_a
+        super().__init__()
+
+    def match_reset_func(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        cluster_a,
+        params: dict,
+        extra: dict,
+        cache: Optional[dict] = None,
+    ) -> bool:
+        """Permits external factors to influence cluster creation.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight / info.
+        cluster_a : int
+            A-side cluster label.
+        params : dict
+            Parameters for the algorithm.
+        extra : dict
+            Additional parameters, including "cluster_b".
+        cache : dict, optional
+            Values cached from previous calculations.
+
+        Returns
+        -------
+        bool
+            True if the match is permitted, False otherwise.
 
         """
         cluster_b = extra["cluster_b"]
@@ -43,17 +81,18 @@ def match_reset_func(
             return False
         return True
 
-    def __init__(self, module_a: BaseART):
-        self.module_a = module_a
-        super().__init__()
-
     def get_params(self, deep: bool = True) -> dict:
-        """
+        """Get parameters of the model.
 
-        Parameters:
-        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+        Parameters
+        ----------
+        deep : bool, default=True
+            If True, will return the parameters for this class and contained subobjects
+            that are estimators.
 
-        Returns:
+        Returns
+        -------
+        dict
             Parameter names mapped to their values.
 
         """
@@ -63,86 +102,142 @@ def get_params(self, deep: bool = True) -> dict:
             out.update(("module_a" + "__" + k, val) for k, val in deep_items)
         return out
 
+    def validate_data(
+        self, X: np.ndarray, y: np.ndarray
+    ) -> tuple[np.ndarray, np.ndarray]:
+        """Validate data prior to clustering.
 
-    def validate_data(self, X: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
-        """
-        validates the data prior to clustering
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A.
+        y : np.ndarray
+            Data set B.
 
-        Parameters:
-        - X: data set A
-        - y: data set B
+        Returns
+        -------
+        tuple[np.ndarray, np.ndarray]
+            The validated datasets X and y.
 
         """
         X, y = check_X_y(X, y, dtype=None)
         self.module_a.validate_data(X)
         return X, y
 
-    def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+    def prepare_data(
+        self, X: np.ndarray, y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
+        """Prepare data for clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set.
+        y : Optional[np.ndarray]
+            Data set B. Not used in SimpleARTMAP
+
+        Returns
+        -------
+        np.ndarray
+            Prepared data.
 
-        Returns:
-            prepared data
         """
         return self.module_a.prepare_data(X)
 
-    def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+    def restore_data(
+        self, X: np.ndarray, y: Optional[np.ndarray] = None
+    ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
+        """Restore data to state prior to preparation.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set.
 
-        Returns:
-            restored data
-        """
-        return self.module_a.restore_data(X)
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-    def step_fit(self, x: np.ndarray, c_b: int, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10) -> int:
         """
-        Fit the model to a single sample
-
-        Parameters:
-        - x: data sample for side A
-        - c_b: side b label
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+        return self.module_a.restore_data(X)
 
-        Returns:
-            side A cluster label
+    def step_fit(
+        self,
+        x: np.ndarray,
+        c_b: int,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+    ) -> int:
+        """Fit the model to a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample for side A.
+        c_b : int
+            Side B label.
+        match_tracking : Literal, default="MT+"
+            Method to reset the match.
+        epsilon : float, default=1e-10
+            Small value to adjust the vigilance.
+
+        Returns
+        -------
+        int
+            Side A cluster label.
 
         """
         match_reset_func = lambda i, w, cluster, params, cache: self.match_reset_func(
-            i, w, cluster, params=params, extra={"cluster_b": c_b}, cache=cache
+            i,
+            w,
+            cluster,
+            params=params,
+            extra={"cluster_b": c_b},
+            cache=cache,
+        )
+        c_a = self.module_a.step_fit(
+            x,
+            match_reset_func=match_reset_func,
+            match_tracking=match_tracking,
+            epsilon=epsilon,
         )
-        c_a = self.module_a.step_fit(x, match_reset_func=match_reset_func, match_reset_method=match_reset_method, epsilon=epsilon)
         if c_a not in self.map:
             self.map[c_a] = c_b
         else:
             assert self.map[c_a] == c_b
         return c_a
 
-    def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10, verbose: bool = False):
-        """
-        Fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: data set B
-        - max_iter: number of iterations to fit the model on the same data set
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        max_iter=1,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+        verbose: bool = False,
+    ):
+        """Fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A.
+        y : np.ndarray
+            Data set B.
+        max_iter : int, default=1
+            Number of iterations to fit the model on the same data set.
+        match_tracking : Literal, default="MT+"
+            Method to reset the match.
+        epsilon : float, default=1e-10
+            Small value to adjust the vigilance.
+        verbose : bool, default=False
+            If True, displays a progress bar during training.
+
+        Returns
+        -------
+        self : SimpleARTMAP
+            The fitted model.
 
         """
         # Check that X and y have correct shape
@@ -157,82 +252,153 @@ def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1, match_reset_method: Lite
         for _ in range(max_iter):
             if verbose:
                 from tqdm import tqdm
+
                 x_y_iter = tqdm(enumerate(zip(X, y)), total=int(X.shape[0]))
             else:
                 x_y_iter = enumerate(zip(X, y))
             for i, (x, c_b) in x_y_iter:
                 self.module_a.pre_step_fit(X)
-                c_a = self.step_fit(x, c_b, match_reset_method=match_reset_method, epsilon=epsilon)
+                c_a = self.step_fit(
+                    x,
+                    c_b,
+                    match_tracking=match_tracking,
+                    epsilon=epsilon,
+                )
                 self.module_a.labels_[i] = c_a
                 self.module_a.post_step_fit(X)
         return self
 
-    def partial_fit(self, X: np.ndarray, y: np.ndarray, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 1e-10):
-        """
-        Partial fit the model to the data
-
-        Parameters:
-        - X: data set A
-        - y: data set B
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def partial_fit(
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 1e-10,
+    ):
+        """Partial fit the model to the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A.
+        y : np.ndarray
+            Data set B.
+        match_tracking : Literal, default="MT+"
+            Method to reset the match.
+        epsilon : float, default=1e-10
+            Small value to adjust the vigilance.
+
+        Returns
+        -------
+        self : SimpleARTMAP
+            The partially fitted model.
 
         """
         SimpleARTMAP.validate_data(self, X, y)
-        if not hasattr(self, 'labels_'):
+        if not hasattr(self, "labels_"):
             self.labels_ = y
             self.module_a.W = []
             self.module_a.labels_ = np.zeros((X.shape[0],), dtype=int)
             j = 0
         else:
             j = len(self.labels_)
-            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode='constant')
+            self.labels_ = np.pad(self.labels_, [(0, X.shape[0])], mode="constant")
             self.labels_[j:] = y
-            self.module_a.labels_ = np.pad(self.module_a.labels_, [(0, X.shape[0])], mode='constant')
+            self.module_a.labels_ = np.pad(
+                self.module_a.labels_, [(0, X.shape[0])], mode="constant"
+            )
         for i, (x, c_b) in enumerate(zip(X, y)):
             self.module_a.pre_step_fit(X)
-            c_a = self.step_fit(x, c_b, match_reset_method=match_reset_method, epsilon=epsilon)
-            self.module_a.labels_[i+j] = c_a
+            c_a = self.step_fit(x, c_b, match_tracking=match_tracking, epsilon=epsilon)
+            self.module_a.labels_[i + j] = c_a
             self.module_a.post_step_fit(X)
         return self
 
     @property
-    def labels_a(self):
+    def labels_a(self) -> np.ndarray:
+        """Get labels from side A (module A).
+
+        Returns
+        -------
+        np.ndarray
+            Labels from module A.
+
+        """
         return self.module_a.labels_
 
     @property
-    def labels_b(self):
+    def labels_b(self) -> np.ndarray:
+        """Get labels from side B.
+
+        Returns
+        -------
+        np.ndarray
+            Labels from side B.
+
+        """
         return self.labels_
 
     @property
-    def labels_ab(self):
+    def labels_ab(self) -> Dict[str, np.ndarray]:
+        """Get labels from both A-side and B-side.
+
+        Returns
+        -------
+        dict
+            A dictionary with keys "A" and "B" containing labels from sides A and B,
+            respectively.
+
+        """
         return {"A": self.labels_a, "B": self.labels_}
 
     @property
-    def n_clusters(self):
+    def n_clusters(self) -> int:
+        """Get the number of clusters in side A.
+
+        Returns
+        -------
+        int
+            Number of clusters.
+
+        """
         return self.module_a.n_clusters
 
     @property
-    def n_clusters_a(self):
+    def n_clusters_a(self) -> int:
+        """Get the number of clusters in side A.
+
+        Returns
+        -------
+        int
+            Number of clusters in side A.
+
+        """
         return self.n_clusters
 
     @property
-    def n_clusters_b(self):
+    def n_clusters_b(self) -> int:
+        """Get the number of clusters in side B.
+
+        Returns
+        -------
+        int
+            Number of clusters in side B.
+
+        """
         return len(set(c for c in self.map.values()))
 
     def step_pred(self, x: np.ndarray) -> tuple[int, int]:
-        """
-        Predict the label for a single sample
+        """Predict the label for a single sample.
 
-        Parameters:
-        - x: data sample for side A
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample for side A.
 
-        Returns:
-            side A cluster label, side B cluster label
+        Returns
+        -------
+        tuple[int, int]
+            Side A cluster label, side B cluster label.
 
         """
         c_a = self.module_a.step_pred(x)
@@ -240,14 +406,17 @@ def step_pred(self, x: np.ndarray) -> tuple[int, int]:
         return c_a, c_b
 
     def predict(self, X: np.ndarray) -> np.ndarray:
-        """
-        predict labels for the data
+        """Predict labels for the data.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A.
 
-        Returns:
-            B labels for the data
+        Returns
+        -------
+        np.ndarray
+            B labels for the data.
 
         """
         check_is_fitted(self)
@@ -258,14 +427,17 @@ def predict(self, X: np.ndarray) -> np.ndarray:
         return y_b
 
     def predict_ab(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
-        """
-        predict labels for the data, both A-side and B-side
+        """Predict labels for the data, both A-side and B-side.
 
-        Parameters:
-        - X: data set A
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set A.
 
-        Returns:
-            A labels for the data, B labels for the data
+        Returns
+        -------
+        tuple[np.ndarray, np.ndarray]
+            A labels for the data, B labels for the data.
 
         """
         check_is_fitted(self)
@@ -277,14 +449,19 @@ def predict_ab(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
             y_b[i] = c_b
         return y_a, y_b
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the cluster boundaries.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, default=1
+            Width of boundary lines.
 
         """
         colors_a = []
@@ -293,24 +470,30 @@ def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
         self.module_a.plot_cluster_bounds(ax, colors_a, linewidth)
 
     def visualize(
-            self,
-            X: np.ndarray,
-            y: np.ndarray,
-            ax: Optional[Axes] = None,
-            marker_size: int = 10,
-            linewidth: int = 1,
-            colors: Optional[Iterable] = None
+        self,
+        X: np.ndarray,
+        y: np.ndarray,
+        ax: Optional[Axes] = None,
+        marker_size: int = 10,
+        linewidth: int = 1,
+        colors: Optional[IndexableOrKeyable] = None,
     ):
-        """
-        Visualize the clustering of the data
-
-        Parameters:
-        - X: data set
-        - y: sample labels
-        - ax: figure axes
-        - marker_size: size used for data points
-        - linewidth: width of boundary line
-        - colors: colors to use for each cluster
+        """Visualize the clustering of the data.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data set.
+        y : np.ndarray
+            Sample labels.
+        ax : Optional[Axes], default=None
+            Figure axes.
+        marker_size : int, default=10
+            Size used for data points.
+        linewidth : int, default=1
+            Width of boundary lines.
+        colors : Optional[Iterable], default=None
+            Colors to use for each cluster.
 
         """
         import matplotlib.pyplot as plt
@@ -320,10 +503,17 @@ def visualize(
 
         if colors is None:
             from matplotlib.pyplot import cm
+
             colors = cm.rainbow(np.linspace(0, 1, self.n_clusters_b))
 
         for k_b, col in enumerate(colors):
             cluster_data = y == k_b
-            plt.scatter(X[cluster_data, 0], X[cluster_data, 1], color=col, marker=".", s=marker_size)
+            plt.scatter(
+                X[cluster_data, 0],
+                X[cluster_data, 1],
+                color=col,
+                marker=".",
+                s=marker_size,
+            )
 
         self.plot_cluster_bounds(ax, colors, linewidth)
diff --git a/artlib/supervised/__init__.py b/artlib/supervised/__init__.py
index e69de29..ea54798 100644
--- a/artlib/supervised/__init__.py
+++ b/artlib/supervised/__init__.py
@@ -0,0 +1,14 @@
+"""
+Supervised learning is a type of machine learning where a model is trained on labeled
+data, meaning that the input data is paired with the correct output. The goal is for the
+model to learn the relationship between inputs and outputs so it can make accurate
+predictions on new, unseen data. Supervised learning tasks can generally be categorized
+into two types: classification and regression.
+
+Classification involves predicting discrete labels or categories, such as spam detection
+or image recognition. Regression, on the other hand, deals with predicting continuous
+values, like stock prices or temperature.
+
+`Supervised learning <https://en.wikipedia.org/wiki/Supervised_learning>`_
+
+"""
diff --git a/artlib/topological/DualVigilanceART.py b/artlib/topological/DualVigilanceART.py
new file mode 100644
index 0000000..145a38e
--- /dev/null
+++ b/artlib/topological/DualVigilanceART.py
@@ -0,0 +1,442 @@
+"""Dual Vigilance ART :cite:`da2019dual`."""
+# Brito da Silva, L. E., Elnabarawy, I., & Wunsch II, D. C. (2019).
+# Dual vigilance fuzzy adaptive resonance theory.
+# Neural Networks, 109, 1–5. doi:10.1016/j.neunet.2018.09.015.
+import numpy as np
+from typing import Optional, Callable, List, Literal, Union, Dict
+from warnings import warn
+from copy import deepcopy
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import IndexableOrKeyable
+
+
+class DualVigilanceART(BaseART):
+    """Dual Vigilance ART for Clustering.
+
+    This module implements Dual Vigilance ART as first published in: :cite:`da2019dual`.
+
+    .. # Brito da Silva, L. E., Elnabarawy, I., & Wunsch II, D. C. (2019).
+    .. # Dual vigilance fuzzy adaptive resonance theory.
+    .. # Neural Networks, 109, 1–5. doi:10.1016/j.neunet.2018.09.015.
+
+    Dual Vigilance ART allows a :class:`~artlib.common.BaseART.BaseART` module to
+    cluster with both an upper and lower vigilance value. The upper-vigilance value
+    allows the :class:`~artlib.common.BaseART.BaseART` module to cluster normally,
+    however, data is simultaneously clustered using the lower vigilance level to
+    combine multiple base ART categories into a single abstracted category. This
+    permits clusters to be combined to form arbitrary shapes. For example if the
+    :class:`~artlib.common.BaseART.BaseART` module is
+    :class:`~artlib.elementary.FuzzyART.FuzzyART`, a Dual Vigilance Fuzzy ART
+    clustering result would look  like a series of hyper-boxes forming an arbitrary
+    geometry.
+
+    """
+
+    def __init__(self, base_module: BaseART, rho_lower_bound: float):
+        """Initialize the Dual Vigilance ART model.
+
+        Parameters
+        ----------
+        base_module : BaseART
+            The instantiated ART module that will serve as the base for dual vigilance.
+        rho_lower_bound : float
+            The lower vigilance value that will "merge" the base_module clusters.
+
+        """
+        assert isinstance(base_module, BaseART)
+        if hasattr(base_module, "base_module"):
+            warn(
+                f"{base_module.__class__.__name__} "
+                f"is an abstraction of the BaseART class. "
+                f"This module will only make use of the base_module: "
+                f"{base_module.base_module.__class__.__name__}"
+            )
+        assert "rho" in base_module.params, (
+            "Dual Vigilance ART is only compatible with ART modules "
+            "relying on 'rho' for vigilance."
+        )
+
+        params = {"rho_lower_bound": rho_lower_bound}
+        assert base_module.params["rho"] > params["rho_lower_bound"] >= 0
+        super().__init__(params)
+        self.base_module = base_module
+        self.rho_lower_bound = rho_lower_bound
+        self.map: dict[int, int] = dict()
+
+    def prepare_data(self, X: np.ndarray) -> np.ndarray:
+        """Prepare data for clustering.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Prepared data from the base module.
+
+        """
+        return self.base_module.prepare_data(X)
+
+    def restore_data(self, X: np.ndarray) -> np.ndarray:
+        """Restore data to its state prior to preparation.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Restored data from the base module.
+
+        """
+        return self.base_module.restore_data(X)
+
+    def get_params(self, deep: bool = True) -> dict:
+        """Get the parameters of the estimator.
+
+        Parameters
+        ----------
+        deep : bool, optional
+            If True, return the parameters for this class and contained subobjects that
+            are estimators, by default True.
+
+        Returns
+        -------
+        dict
+            Parameter names mapped to their values.
+
+        """
+        out = {
+            "rho_lower_bound": self.params["rho_lower_bound"],
+            "base_module": self.base_module,
+        }
+        if deep:
+            deep_items = self.base_module.get_params().items()
+            out.update(("base_module" + "__" + k, val) for k, val in deep_items)
+
+        return out
+
+    @property
+    def n_clusters(self) -> int:
+        """Get the current number of clusters.
+
+        Returns
+        -------
+        int
+            The number of clusters.
+
+        """
+        return len(set(c for c in self.map.values()))
+
+    @property
+    def dim_(self):
+        """Get the dimensionality of the data from the base module.
+
+        Returns
+        -------
+        int
+            Dimensionality of the data.
+
+        """
+        return self.base_module.dim_
+
+    @dim_.setter
+    def dim_(self, new_dim):
+        self.base_module.dim_ = new_dim
+
+    @property
+    def labels_(self):
+        """Get the labels from the base module.
+
+        Returns
+        -------
+        np.ndarray
+            Labels for the data.
+
+        """
+        return self.base_module.labels_
+
+    @labels_.setter
+    def labels_(self, new_labels: np.ndarray):
+        self.base_module.labels_ = new_labels
+
+    @property
+    def W(self) -> List:
+        """Get the weights from the base module.
+
+        Returns
+        -------
+        list of np.ndarray
+            Weights of the clusters.
+
+        """
+        return self.base_module.W
+
+    @W.setter
+    def W(self, new_W: list[np.ndarray]):
+        self.base_module.W = new_W
+
+    def check_dimensions(self, X: np.ndarray):
+        """Check that the data has the correct dimensions.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        """
+        self.base_module.check_dimensions(X)
+
+    def validate_data(self, X: np.ndarray):
+        """Validate the data prior to clustering.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The dataset.
+
+        """
+        self.base_module.validate_data(X)
+        self.check_dimensions(X)
+
+    @staticmethod
+    def validate_params(params: dict):
+        """Validate clustering parameters.
+
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        """
+        assert (
+            "rho_lower_bound" in params
+        ), "Dual Vigilance ART requires a lower bound 'rho' value"
+        assert params["rho_lower_bound"] >= 0
+        assert isinstance(params["rho_lower_bound"], float)
+
+    def _match_tracking(
+        self,
+        cache: Union[List[Dict], Dict],
+        epsilon: float,
+        params: Union[List[Dict], Dict],
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Adjust match tracking based on the method and epsilon value.
+
+        Parameters
+        ----------
+        cache : dict
+            Cache containing intermediate results, including the match criterion.
+        epsilon : float
+            Adjustment factor for the match criterion.
+        params : dict
+            Dictionary containing algorithm parameters.
+        method : {"MT+", "MT-", "MT0", "MT1", "MT~"}
+            Match tracking method to use.
+
+        Returns
+        -------
+        bool
+            True if match tracking continues, False otherwise.
+
+        """
+        assert isinstance(cache, dict)
+        assert isinstance(params, dict)
+        M = cache["match_criterion"]
+        if method == "MT+":
+            self.base_module.params["rho"] = M + epsilon
+            return True
+        elif method == "MT-":
+            self.base_module.params["rho"] = M - epsilon
+            return True
+        elif method == "MT0":
+            self.base_module.params["rho"] = M
+            return True
+        elif method == "MT1":
+            self.base_module.params["rho"] = np.inf
+            return False
+        elif method == "MT~":
+            return True
+        else:
+            raise ValueError(f"Invalid Match Tracking Method: {method}")
+
+    def _set_params(self, new_params):
+        self.base_module.params = new_params
+
+    def _deep_copy_params(self) -> dict:
+        return deepcopy(self.base_module.params)
+
+    def step_fit(
+        self,
+        x: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ) -> int:
+        """Fit the model to a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        match_reset_func : callable, optional
+            A callable accepting the data sample, a cluster weight, the params dict,
+            and the cache dict.
+            Returns True if the cluster is valid for the sample, False otherwise.
+        match_tracking : {"MT+", "MT-", "MT0", "MT1", "MT~"}, optional
+            Method for resetting match criterion, by default "MT+".
+        epsilon : float, optional
+            Epsilon value used for adjusting match criterion, by default 0.0.
+
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
+
+        """
+        base_params = self._deep_copy_params()
+        mt_operator = self._match_tracking_operator(match_tracking)
+        self.sample_counter_ += 1
+        if len(self.base_module.W) == 0:
+            new_w = self.base_module.new_weight(x, self.base_module.params)
+            self.base_module.add_weight(new_w)
+            self.map[0] = 0
+            return 0
+        else:
+            T_values, T_cache = zip(
+                *[
+                    self.base_module.category_choice(
+                        x, w, params=self.base_module.params
+                    )
+                    for w in self.base_module.W
+                ]
+            )
+            T = np.array(T_values)
+            while any(T > 0):
+                c_ = int(np.nanargmax(T))
+                w = self.base_module.W[c_]
+                cache = T_cache[c_]
+                m1, cache = self.base_module.match_criterion_bin(
+                    x,
+                    w,
+                    params=self.base_module.params,
+                    cache=cache,
+                    op=mt_operator,
+                )
+                no_match_reset = match_reset_func is None or match_reset_func(
+                    x,
+                    w,
+                    self.map[c_],
+                    params=self.base_module.params,
+                    cache=cache,
+                )
+
+                if no_match_reset:
+                    if m1:
+                        new_w = self.base_module.update(
+                            x, w, self.base_module.params, cache=cache
+                        )
+                        self.base_module.set_weight(c_, new_w)
+                        self._set_params(base_params)
+                        return self.map[c_]
+                    else:
+                        lb_params = dict(
+                            self.base_module.params,
+                            **{"rho": self.rho_lower_bound},
+                        )
+                        m2, _ = self.base_module.match_criterion_bin(
+                            x, w, params=lb_params, cache=cache, op=mt_operator
+                        )
+                        if m2:
+                            c_new = len(self.base_module.W)
+                            w_new = self.base_module.new_weight(
+                                x, self.base_module.params
+                            )
+                            self.base_module.add_weight(w_new)
+                            self.map[c_new] = self.map[c_]
+                            self._set_params(base_params)
+                            return self.map[c_new]
+                else:
+                    keep_searching = self._match_tracking(
+                        cache, epsilon, self.params, match_tracking
+                    )
+                    if not keep_searching:
+                        T[:] = np.nan
+                T[c_] = np.nan
+
+            c_new = len(self.base_module.W)
+            w_new = self.base_module.new_weight(x, self.base_module.params)
+            self.base_module.add_weight(w_new)
+            self.map[c_new] = max(self.map.values()) + 1
+            self._set_params(base_params)
+            return self.map[c_new]
+
+    def step_pred(self, x) -> int:
+        """Predict the label for a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
+
+        """
+        assert len(self.base_module.W) >= 0, "ART module is not fit."
+        T, _ = zip(
+            *[
+                self.base_module.category_choice(x, w, params=self.base_module.params)
+                for w in self.base_module.W
+            ]
+        )
+        c_ = int(np.argmax(T))
+        return self.map[c_]
+
+    def get_cluster_centers(self) -> List[np.ndarray]:
+        """Get the centers of each cluster, used for regression.
+
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
+
+        """
+        return self.base_module.get_cluster_centers()
+
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the bounds of each cluster.
+
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, optional
+            Width of boundary line, by default 1.
+
+        """
+        colors_base = []
+        for k_a in range(self.base_module.n_clusters):
+            colors_base.append(colors[self.map[k_a]])
+
+        try:
+            self.base_module.plot_cluster_bounds(
+                ax=ax, colors=colors_base, linewidth=linewidth
+            )
+        except NotImplementedError:
+            warn(
+                f"{self.base_module.__class__.__name__} "
+                f"does not support plotting cluster bounds."
+            )
diff --git a/artlib/topological/TopoART.py b/artlib/topological/TopoART.py
index c54994a..f0c226e 100644
--- a/artlib/topological/TopoART.py
+++ b/artlib/topological/TopoART.py
@@ -1,32 +1,69 @@
-"""
-Tscherepanow, M. (2010).
-TopoART: A Topology Learning Hierarchical ART Network.
-In K. Diamantaras, W. Duch, & L. S. Iliadis (Eds.),
-Artificial Neural Networks – ICANN 2010 (pp. 157–167).
-Berlin, Heidelberg: Springer Berlin Heidelberg.
-doi:10.1007/978-3-642-15825-4_21.
-
-"""
+"""Topo ART :cite:`tscherepanow2010topoart`."""
+# Tscherepanow, M. (2010).
+# TopoART: A Topology Learning Hierarchical ART Network.
+# In K. Diamantaras, W. Duch, & L. S. Iliadis (Eds.),
+# Artificial Neural Networks – ICANN 2010 (pp. 157–167).
+# Berlin, Heidelberg: Springer Berlin Heidelberg.
+# doi:10.1007/978-3-642-15825-4_21.
 
 import numpy as np
-from typing import Optional, Callable, Iterable, List, Literal
+from typing import Optional, Callable, List, Literal, Tuple, Union, Dict
 from matplotlib.axes import Axes
 from warnings import warn
 from copy import deepcopy
 from artlib.common.BaseART import BaseART
+from artlib.common.utils import IndexableOrKeyable
 import operator
 
 
 class TopoART(BaseART):
+    """Topo ART for Topological Clustering.
+
+    This module implements Topo ART as first published in:
+    :cite:`tscherepanow2010topoart`.
+
+    .. # Tscherepanow, M. (2010).
+    .. # TopoART: A Topology Learning Hierarchical ART Network.
+    .. # In K. Diamantaras, W. Duch, & L. S. Iliadis (Eds.),
+    .. # Artificial Neural Networks – ICANN 2010 (pp. 157–167).
+    .. # Berlin, Heidelberg: Springer Berlin Heidelberg.
+    .. # doi:10.1007/978-3-642-15825-4_21.
+
+    Topo ART clusters accepts an instantiated :class:`~artlib.common.BaseART.BaseART`
+    module and generates a topological clustering by recording the first and second
+    resonant cluster relationships in an adjacency matrix. Further, it updates the
+    second resonant cluster with a lower learning rate than the first, providing for
+    a distributed learning model.
+
+    """
+
+    def __init__(self, base_module: BaseART, beta_lower: float, tau: int, phi: int):
+        """Initialize TopoART.
+
+        Parameters
+        ----------
+        base_module : BaseART
+            An instantiated ART module.
+        beta_lower : float
+            The learning rate for the second resonant cluster.
+        tau : int
+            Number of samples after which clusters are pruned.
+        phi : int
+            Minimum number of samples a cluster must be associated with to be kept.
 
-    def __init__(self, base_module: BaseART, betta_lower: float, tau: int, phi: int):
+        """
         assert isinstance(base_module, BaseART)
         if hasattr(base_module, "base_module"):
             warn(
-                f"{base_module.__class__.__name__} is an abstraction of the BaseART class. "
-                f"This module will only make use of the base_module {base_module.base_module.__class__.__name__}"
+                f"{base_module.__class__.__name__} "
+                f"is an abstraction of the BaseART class. "
+                f"This module will only make use of the base_module: "
+                f"{base_module.base_module.__class__.__name__}"
             )
-        params = dict(base_module.params, **{"beta_lower": betta_lower, "tau": tau, "phi": phi})
+        params = dict(
+            base_module.params,
+            **{"beta_lower": beta_lower, "tau": tau, "phi": phi},
+        )
         super().__init__(params)
         self.base_module = base_module
         self.adjacency = np.zeros([], dtype=int)
@@ -34,14 +71,22 @@ def __init__(self, base_module: BaseART, betta_lower: float, tau: int, phi: int)
 
     @staticmethod
     def validate_params(params: dict):
-        """
-        validate clustering parameters
+        """Validate clustering parameters.
+
+        Parameters
+        ----------
+        params : dict
+            A dictionary containing parameters for the algorithm.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Raises
+        ------
+        AssertionError
+            If the required parameters are not provided or are invalid.
 
         """
-        assert "beta" in params, "TopoART is only compatible with ART modules relying on 'beta' for learning."
+        assert (
+            "beta" in params
+        ), "TopoART is only compatible with ART modules relying on 'beta' for learning."
         assert "beta_lower" in params
         assert "tau" in params
         assert "phi" in params
@@ -53,135 +98,209 @@ def validate_params(params: dict):
         assert isinstance(params["phi"], int)
 
     @property
-    def W(self):
+    def W(self) -> List[np.ndarray]:
+        """Get the weight matrix of the base module.
+
+        Returns
+        -------
+        list[np.ndarray]
+            The weight matrix of the base ART module.
+
+        """
         return self.base_module.W
 
     @W.setter
     def W(self, new_W: list[np.ndarray]):
+        """Set the weight matrix of the base module.
+
+        Parameters
+        ----------
+        new_W : list[np.ndarray]
+            The new weight matrix.
+
+        """
         self.base_module.W = new_W
 
     def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+        """Validate the data prior to clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
 
         """
         self.base_module.validate_data(X)
 
     def prepare_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        prepare data for clustering
+        """Prepare data for clustering.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
+
+        Returns
+        -------
+        np.ndarray
+            Prepared (normalized) data.
 
-        Returns:
-            normalized data
         """
         return self.base_module.prepare_data(X)
 
     def restore_data(self, X: np.ndarray) -> np.ndarray:
-        """
-        restore data to state prior to preparation
+        """Restore data to the state prior to preparation.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
 
-        Returns:
-            restored data
-        """
-        return self.base_module.restore_data(X)
+        Returns
+        -------
+        np.ndarray
+            Restored data.
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
         """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        return self.base_module.restore_data(X)
 
-        Returns:
-            cluster activation, cache used for later processing
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Parameters for the algorithm.
+
+        Returns
+        -------
+        tuple[float, Optional[dict]]
+            Cluster activation and cache used for later processing.
 
         """
         return self.base_module.category_choice(i, w, params)
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Parameters for the algorithm.
+        cache : dict, optional
+            Values cached from previous calculations.
+
+        Returns
+        -------
+        tuple[float, dict]
+            Cluster match criterion and cache used for later processing.
 
         """
         return self.base_module.match_criterion(i, w, params, cache)
 
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None, op: Callable = operator.ge) -> tuple[bool, dict]:
-        """
-        get the binary match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion binary, cache used for later processing
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+        op: Callable = operator.ge,
+    ) -> tuple[bool, dict]:
+        """Get the binary match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Parameters for the algorithm.
+        cache : dict, optional
+            Values cached from previous calculations.
+        op : Callable, default=operator.ge
+            Comparison operator to use for the binary match criterion.
+
+        Returns
+        -------
+        tuple[bool, dict]
+            Binary match criterion and cache used for later processing.
 
         """
         return self.base_module.match_criterion_bin(i, w, params, cache, op)
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            updated cluster weight, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        """Update the cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Parameters for the algorithm.
+        cache : dict, optional
+            Values cached from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
+        assert cache is not None
         if cache.get("resonant_c", -1) >= 0:
             self.adjacency[cache["resonant_c"], cache["current_c"]] += 1
         return self.base_module.update(i, w, params, cache)
 
     def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Parameters for the algorithm.
 
-        Returns:
-            updated cluster weight
+        Returns
+        -------
+        np.ndarray
+            Newly generated cluster weight.
 
         """
-
         return self.base_module.new_weight(i, params)
 
-
     def add_weight(self, new_w: np.ndarray):
-        """
-        add a new cluster weight
+        """Add a new cluster weight.
 
-        Parameters:
-        - new_w: new cluster weight to add
+        Parameters
+        ----------
+        new_w : np.ndarray
+            New cluster weight to add.
 
         """
         if len(self.W) == 0:
@@ -192,13 +311,36 @@ def add_weight(self, new_w: np.ndarray):
         self.weight_sample_counter_.append(1)
         self.W.append(new_w)
 
-
     def prune(self, X: np.ndarray):
-        self._permanent_mask += (np.array(self.weight_sample_counter_) >= self.phi)
+        """Prune clusters based on the number of associated samples.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
+
+        """
+        a = (
+            np.array(self.weight_sample_counter_).reshape(
+                -1,
+            )
+            >= self.phi
+        )
+        b = self._permanent_mask
+        print(a.shape, b.shape)
+
+        self._permanent_mask += (
+            np.array(self.weight_sample_counter_).reshape(
+                -1,
+            )
+            >= self.phi
+        )
         perm_labels = np.where(self._permanent_mask)[0]
 
         self.W = [w for w, pm in zip(self.W, self._permanent_mask) if pm]
-        self.weight_sample_counter_ = [self.weight_sample_counter_[i] for i in perm_labels]
+        self.weight_sample_counter_ = [
+            self.weight_sample_counter_[i] for i in perm_labels
+        ]
         self.adjacency = self.adjacency[perm_labels][:, perm_labels]
         self._permanent_mask = self._permanent_mask[perm_labels]
 
@@ -218,20 +360,49 @@ def prune(self, X: np.ndarray):
                 self.labels_[i] = -1
 
     def post_step_fit(self, X: np.ndarray):
-        """
-        Function called after each sample fit. Used for cluster pruning
+        """Perform post-fit operations, such as cluster pruning, after fitting each
+        sample.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        X : np.ndarray
+            The input dataset.
 
         """
         if self.sample_counter_ > 0 and self.sample_counter_ % self.tau == 0:
             self.prune(X)
 
-    def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"]) -> bool:
+    def _match_tracking(
+        self,
+        cache: Union[List[Dict], Dict],
+        epsilon: float,
+        params: Union[List[Dict], Dict],
+        method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"],
+    ) -> bool:
+        """Adjust the vigilance parameter based on match tracking methods.
+
+        Parameters
+        ----------
+        cache : dict
+            Cached values from previous calculations.
+        epsilon : float
+            Adjustment factor for the vigilance parameter.
+        params : dict
+            Parameters for the algorithm.
+        method : Literal["MT+", "MT-", "MT0", "MT1", "MT~"]
+            Method to use for match tracking.
+
+        Returns
+        -------
+        bool
+            True if the match tracking continues, False otherwise.
+
+        """
+        assert isinstance(cache, dict)
+        assert isinstance(params, dict)
         M = cache["match_criterion"]
         if method == "MT+":
-            self.base_module.params["rho"] = M+epsilon
+            self.base_module.params["rho"] = M + epsilon
             return True
         elif method == "MT-":
             self.base_module.params["rho"] = M - epsilon
@@ -248,34 +419,55 @@ def _match_tracking(self, cache: dict, epsilon: float, params: dict, method: Lit
             raise ValueError(f"Invalid Match Tracking Method: {method}")
 
     def _set_params(self, new_params):
-        self.base_module.params = new_params
+        """Set new parameters for the base module.
 
-    def _deep_copy_params(self) -> dict:
-        return deepcopy(self.base_module.params)
+        Parameters
+        ----------
+        new_params : dict
+            New parameters to set.
 
-    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, match_reset_method: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+", epsilon: float = 0.0) -> int:
         """
-        fit the model to a single sample
+        self.base_module.params = new_params
 
-        Parameters:
-        - x: data sample
-        - match_reset_func: a callable accepting the data sample, a cluster weight, the params dict, and the cache dict
-            Permits external factors to influence cluster creation.
-            Returns True if the cluster is valid for the sample, False otherwise
-        - match_reset_method:
-            "MT+": Original method, rho=M+epsilon
-             "MT-": rho=M-epsilon
-             "MT0": rho=M, using > operator
-             "MT1": rho=1.0,  Immediately create a new cluster on mismatch
-             "MT~": do not change rho
+    def _deep_copy_params(self) -> dict:
+        """Create a deep copy of the parameters.
+
+        Returns
+        -------
+        dict
+            Deep copy of the parameters.
 
+        """
+        return deepcopy(self.base_module.params)
 
-        Returns:
-            cluster label of the input sample
+    def step_fit(
+        self,
+        x: np.ndarray,
+        match_reset_func: Optional[Callable] = None,
+        match_tracking: Literal["MT+", "MT-", "MT0", "MT1", "MT~"] = "MT+",
+        epsilon: float = 0.0,
+    ) -> int:
+        """Fit the model to a single sample.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            Data sample.
+        match_reset_func : Callable, optional
+            Function to reset the match based on custom criteria.
+        match_tracking : Literal["MT+", "MT-", "MT0", "MT1", "MT~"], default="MT+"
+            Method to reset the match.
+        epsilon : float, default=0.0
+            Adjustment factor for vigilance.
+
+        Returns
+        -------
+        int
+            Cluster label of the input sample.
 
         """
         base_params = self._deep_copy_params()
-        mt_operator = self._match_tracking_operator(match_reset_method)
+        mt_operator = self._match_tracking_operator(match_tracking)
         self.sample_counter_ += 1
         resonant_c: int = -1
 
@@ -283,31 +475,47 @@ def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, m
             new_w = self.new_weight(x, self.params)
             self.add_weight(new_w)
             self.adjacency = np.zeros((1, 1), dtype=int)
-            self._permanent_mask = np.zeros((1, ), dtype=bool)
+            self._permanent_mask = np.zeros((1,), dtype=bool)
             return 0
         else:
-            T_values, T_cache = zip(*[self.category_choice(x, w, params=self.base_module.params) for w in self.W])
+            T_values, T_cache = zip(
+                *[
+                    self.category_choice(x, w, params=self.base_module.params)
+                    for w in self.W
+                ]
+            )
             T = np.array(T_values)
             while any(~np.isnan(T)):
                 c_ = int(np.nanargmax(T))
                 w = self.W[c_]
                 cache = T_cache[c_]
-                m, cache = self.match_criterion_bin(x, w, params=self.base_module.params, cache=cache, op=mt_operator)
-                no_match_reset = (
-                        match_reset_func is None or
-                        match_reset_func(x, w, c_, params=self.base_module.params, cache=cache)
+                m, cache = self.match_criterion_bin(
+                    x,
+                    w,
+                    params=self.base_module.params,
+                    cache=cache,
+                    op=mt_operator,
+                )
+                no_match_reset = match_reset_func is None or match_reset_func(
+                    x, w, c_, params=self.base_module.params, cache=cache
                 )
                 if m and no_match_reset:
                     if resonant_c < 0:
                         params = self.base_module.params
                     else:
-                        params = dict(self.base_module.params, **{"beta": self.params["beta_lower"]})
-                    #TODO: make compatible with DualVigilanceART
+                        params = dict(
+                            self.base_module.params,
+                            **{"beta": self.params["beta_lower"]},
+                        )
+                    # TODO: make compatible with DualVigilanceART
                     new_w = self.update(
                         x,
                         w,
                         params=params,
-                        cache=dict((cache if cache else {}), **{"resonant_c": resonant_c, "current_c": c_})
+                        cache=dict(
+                            (cache if cache else {}),
+                            **{"resonant_c": resonant_c, "current_c": c_},
+                        ),
                     )
                     self.set_weight(c_, new_w)
                     if resonant_c < 0:
@@ -319,7 +527,9 @@ def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, m
                 else:
                     T[c_] = np.nan
                     if not no_match_reset:
-                        keep_searching = self._match_tracking(cache, epsilon, self.params, match_reset_method)
+                        keep_searching = self._match_tracking(
+                            cache, epsilon, self.params, match_tracking
+                        )
                         if not keep_searching:
                             T[:] = np.nan
 
@@ -332,26 +542,38 @@ def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None, m
 
             return resonant_c
 
-
     def get_cluster_centers(self) -> List[np.ndarray]:
-        """
-        function for getting centers of each cluster. Used for regression
-        Returns:
-            cluster centroid
+        """Get the centers of each cluster.
+
+        Returns
+        -------
+        List[np.ndarray]
+            Cluster centroids.
+
         """
         return self.base_module.get_cluster_centers()
 
-    def plot_cluster_bounds(self, ax: Axes, colors: Iterable, linewidth: int = 1):
-        """
-        undefined function for visualizing the bounds of each cluster
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Visualize the boundaries of each cluster.
 
-        Parameters:
-        - ax: figure axes
-        - colors: colors to use for each cluster
-        - linewidth: width of boundary line
+        Parameters
+        ----------
+        ax : Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, default=1
+            Width of boundary lines.
 
         """
         try:
-            self.base_module.plot_cluster_bounds(ax=ax, colors=colors, linewidth=linewidth)
+            self.base_module.plot_cluster_bounds(
+                ax=ax, colors=colors, linewidth=linewidth
+            )
         except NotImplementedError:
-            warn(f"{self.base_module.__class__.__name__} does not support plotting cluster bounds.")
\ No newline at end of file
+            warn(
+                f"{self.base_module.__class__.__name__} "
+                f"does not support plotting cluster bounds."
+            )
diff --git a/artlib/topological/__init__.py b/artlib/topological/__init__.py
index e69de29..e8e583a 100644
--- a/artlib/topological/__init__.py
+++ b/artlib/topological/__init__.py
@@ -0,0 +1,17 @@
+"""Topological clustering is a method of grouping data points based on their topological
+structure, capturing the shape or connectivity of the data rather than relying solely on
+distance measures. This approach is particularly useful when the data has a non-linear
+structure or when traditional clustering algorithms fail to capture the intrinsic
+geometry of the data. Topological clustering techniques, such as hierarchical clustering
+and Mapper, are often used in fields like data analysis and computational topology.
+
+The two modules herein provide contrasting advantages.
+:class:`~artlib.topological.TopoART.TopoART` allows for the creation of an adjacency
+matrix which can be useful when clusters overlap or are in close proximity.
+:class:`~artlib.topological.DualVigilanceART.DualVigilanceART` allows for the abstract
+merging of many smaller clusters and is well suited to problems where the clusters
+take-on complex geometries where other clustering approaches would fail.
+
+`Topological clustering <https://en.wikipedia.org/wiki/Topological_data_analysis>`_
+
+"""
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/img/comparison_of_elementary_methods.jpg b/docs/_static/img/comparison_of_elementary_methods.jpg
similarity index 100%
rename from img/comparison_of_elementary_methods.jpg
rename to docs/_static/img/comparison_of_elementary_methods.jpg
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..747ffb7
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.https://www.sphinx-doc.org/
+	exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/source/artlib.biclustering.rst b/docs/source/artlib.biclustering.rst
new file mode 100644
index 0000000..38d7512
--- /dev/null
+++ b/docs/source/artlib.biclustering.rst
@@ -0,0 +1,23 @@
+artlib.biclustering package
+===========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.biclustering
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.biclustering.BARTMAP module
+----------------------------------
+
+.. automodule:: artlib.biclustering.BARTMAP
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.common.rst b/docs/source/artlib.common.rst
new file mode 100644
index 0000000..77a39ca
--- /dev/null
+++ b/docs/source/artlib.common.rst
@@ -0,0 +1,54 @@
+artlib.common package
+=====================
+
+Module contents
+---------------
+
+.. automodule:: artlib.common
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.common.BaseART module
+----------------------------
+
+.. automodule:: artlib.common.BaseART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.common.BaseARTMAP module
+-------------------------------
+
+.. automodule:: artlib.common.BaseARTMAP
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.common.VAT module
+------------------------
+
+.. automodule:: artlib.common.VAT
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.common.utils module
+--------------------------
+
+.. automodule:: artlib.common.utils
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.common.visualization module
+----------------------------------
+
+.. automodule:: artlib.common.visualization
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
diff --git a/docs/source/artlib.cvi.iCVIs.rst b/docs/source/artlib.cvi.iCVIs.rst
new file mode 100644
index 0000000..023924e
--- /dev/null
+++ b/docs/source/artlib.cvi.iCVIs.rst
@@ -0,0 +1,23 @@
+artlib.cvi.iCVIs package
+========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.cvi.iCVIs
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.cvi.iCVIs.CalinkskiHarabasz module
+-----------------------------------------
+
+.. automodule:: artlib.cvi.iCVIs.CalinkskiHarabasz
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.cvi.rst b/docs/source/artlib.cvi.rst
new file mode 100644
index 0000000..8b22857
--- /dev/null
+++ b/docs/source/artlib.cvi.rst
@@ -0,0 +1,39 @@
+artlib.cvi package
+==================
+
+Module contents
+---------------
+
+.. automodule:: artlib.cvi
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   artlib.cvi.iCVIs
+
+Submodules
+----------
+
+artlib.cvi.CVIART module
+------------------------
+
+.. automodule:: artlib.cvi.CVIART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.cvi.iCVIFuzzyArt module
+------------------------------
+
+.. automodule:: artlib.cvi.iCVIFuzzyArt
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.elementary.rst b/docs/source/artlib.elementary.rst
new file mode 100644
index 0000000..87ec705
--- /dev/null
+++ b/docs/source/artlib.elementary.rst
@@ -0,0 +1,79 @@
+artlib.elementary package
+=========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.elementary
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.elementary.ART1 module
+-----------------------------
+
+.. automodule:: artlib.elementary.ART1
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.ART2 module
+-----------------------------
+
+.. automodule:: artlib.elementary.ART2
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.BayesianART module
+------------------------------------
+
+.. automodule:: artlib.elementary.BayesianART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.EllipsoidART module
+-------------------------------------
+
+.. automodule:: artlib.elementary.EllipsoidART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.FuzzyART module
+---------------------------------
+
+.. automodule:: artlib.elementary.FuzzyART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.GaussianART module
+------------------------------------
+
+.. automodule:: artlib.elementary.GaussianART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.HypersphereART module
+---------------------------------------
+
+.. automodule:: artlib.elementary.HypersphereART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.elementary.QuadraticNeuronART module
+-------------------------------------------
+
+.. automodule:: artlib.elementary.QuadraticNeuronART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.experimental.rst b/docs/source/artlib.experimental.rst
new file mode 100644
index 0000000..c973744
--- /dev/null
+++ b/docs/source/artlib.experimental.rst
@@ -0,0 +1,39 @@
+artlib.experimental package
+===========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.experimental
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.experimental.HullART module
+----------------------------------------
+
+.. automodule:: artlib.experimental.HullART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.experimental.SeqART module
+---------------------------------
+
+.. automodule:: artlib.experimental.SeqART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.experimental.merging module
+----------------------------------
+
+.. automodule:: artlib.experimental.merging
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.fusion.rst b/docs/source/artlib.fusion.rst
new file mode 100644
index 0000000..354cb6f
--- /dev/null
+++ b/docs/source/artlib.fusion.rst
@@ -0,0 +1,23 @@
+artlib.fusion package
+=====================
+
+Module contents
+---------------
+
+.. automodule:: artlib.fusion
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.fusion.FusionART module
+------------------------------
+
+.. automodule:: artlib.fusion.FusionART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.hierarchical.rst b/docs/source/artlib.hierarchical.rst
new file mode 100644
index 0000000..1eb9acd
--- /dev/null
+++ b/docs/source/artlib.hierarchical.rst
@@ -0,0 +1,31 @@
+artlib.hierarchical package
+===========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.hierarchical
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.hierarchical.DeepARTMAP module
+-------------------------------------
+
+.. automodule:: artlib.hierarchical.DeepARTMAP
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.hierarchical.SMART module
+--------------------------------
+
+.. automodule:: artlib.hierarchical.SMART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.reinforcement.rst b/docs/source/artlib.reinforcement.rst
new file mode 100644
index 0000000..c9aaa86
--- /dev/null
+++ b/docs/source/artlib.reinforcement.rst
@@ -0,0 +1,23 @@
+artlib.reinforcement package
+============================
+
+Module contents
+---------------
+
+.. automodule:: artlib.reinforcement
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.reinforcement.FALCON module
+----------------------------------
+
+.. automodule:: artlib.reinforcement.FALCON
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.rst b/docs/source/artlib.rst
new file mode 100644
index 0000000..8a27f50
--- /dev/null
+++ b/docs/source/artlib.rst
@@ -0,0 +1,29 @@
+artlib package
+==============
+
+Module contents
+---------------
+
+.. automodule:: artlib
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   artlib.biclustering
+   artlib.common
+   artlib.cvi
+   artlib.elementary
+   artlib.experimental
+   artlib.fusion
+   artlib.hierarchical
+   artlib.reinforcement
+   artlib.supervised
+   artlib.topological
+
+
diff --git a/docs/source/artlib.supervised.rst b/docs/source/artlib.supervised.rst
new file mode 100644
index 0000000..882670e
--- /dev/null
+++ b/docs/source/artlib.supervised.rst
@@ -0,0 +1,31 @@
+artlib.supervised package
+=========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.supervised
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.supervised.ARTMAP module
+-------------------------------
+
+.. automodule:: artlib.supervised.ARTMAP
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.supervised.SimpleARTMAP module
+-------------------------------------
+
+.. automodule:: artlib.supervised.SimpleARTMAP
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/artlib.topological.rst b/docs/source/artlib.topological.rst
new file mode 100644
index 0000000..f10b26a
--- /dev/null
+++ b/docs/source/artlib.topological.rst
@@ -0,0 +1,31 @@
+artlib.topological package
+==========================
+
+Module contents
+---------------
+
+.. automodule:: artlib.topological
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+artlib.topological.DualVigilanceART module
+------------------------------------------
+
+.. automodule:: artlib.topological.DualVigilanceART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+artlib.topological.TopoART module
+---------------------------------
+
+.. automodule:: artlib.topological.TopoART
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
diff --git a/docs/source/available_models.rst b/docs/source/available_models.rst
new file mode 100644
index 0000000..fcc20cf
--- /dev/null
+++ b/docs/source/available_models.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START available_models -->
+   :end-before: <!-- END available_models -->
+
diff --git a/docs/source/citation.rst b/docs/source/citation.rst
new file mode 100644
index 0000000..035e8bf
--- /dev/null
+++ b/docs/source/citation.rst
@@ -0,0 +1,12 @@
+Citation
+==================================
+If you use this project in your research, please cite it as:
+
+.. bibliography:: artlib_citation.bib
+   :style: unsrt
+   :all:
+
+Alternatively, you can cite the project using the following BibTeX entry:
+
+.. literalinclude:: artlib_citation.bib
+   :language: bibtex
diff --git a/docs/source/comparison.rst b/docs/source/comparison.rst
new file mode 100644
index 0000000..3765d81
--- /dev/null
+++ b/docs/source/comparison.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START comparison_of_elementary_models -->
+   :end-before: <!-- END comparison_of_elementary_models -->
+
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..68f3418
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,66 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+def skip(app, what, name, obj, would_skip, options):
+    if name == "__init__":
+        return False
+    return would_skip
+
+def setup(app):
+    app.connect("autodoc-skip-member", skip)
+
+project = 'AdaptiveResonanceLib'
+copyright = '2024, Niklas Melton'
+author = 'Niklas Melton'
+release = '0.1.2'
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+    'sphinx.ext.autodoc',
+    'autoapi.extension',
+    'sphinx.ext.napoleon',
+    'myst_parser',
+    'sphinx.ext.intersphinx',
+    'sphinxcontrib.bibtex',
+]
+
+source_suffix = {
+    '.rst': 'restructuredtext',
+    '.md': 'markdown',
+}
+
+templates_path = ['_templates']
+exclude_patterns = ['artlib/experimental/*', '../../artlib/experimental/*']
+
+autoapi_type = 'python'
+autoapi_dirs = ['../../artlib']  # Adjust this to point to your source code directory
+autoapi_ignore = ['*/experimental', '*/experimental/*']
+# autoapi_python_class_content = 'both'
+# autoclass_content = 'both'
+
+bibtex_bibfiles = ['artlib_citation.bib', '../../references.bib']
+
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3', None),
+    'sklearn': ('https://scikit-learn.org/stable/', None)
+}
+
+suppress_warnings = ['ref.duplicate', 'duplicate.object', 'myst.duplicate_def', 'ref.python']
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = 'sphinx_rtd_theme'
+html_static_path = ['../_static']
+
+
+
+
+
diff --git a/docs/source/contact.rst b/docs/source/contact.rst
new file mode 100644
index 0000000..0fc9e70
--- /dev/null
+++ b/docs/source/contact.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START contact -->
+   :end-before: <!-- END contact -->
+
diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
new file mode 100644
index 0000000..38baf90
--- /dev/null
+++ b/docs/source/contributing.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START contributing -->
+   :end-before: <!-- END contributing -->
+
diff --git a/docs/source/examples.rst b/docs/source/examples.rst
new file mode 100644
index 0000000..5983c5b
--- /dev/null
+++ b/docs/source/examples.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START examples -->
+   :end-before: <!-- END examples -->
+
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..1573794
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,41 @@
+.. AdaptiveResonanceLib documentation master file, created by
+   sphinx-quickstart on Fri Oct 11 17:02:51 2024.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+AdaptiveResonanceLib Home
+==================================
+
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Main Contents:
+
+   installation
+   available_models
+   comparison
+   quick_start
+   examples
+   contributing
+   contact
+   license
+   references
+   citation
+
+.. toctree::
+   :maxdepth: 2
+   :caption: API Reference:
+
+   artlib
+   artlib.biclustering
+   artlib.common
+   artlib.cvi
+   artlib.elementary
+   artlib.fusion
+   artlib.hierarchical
+   artlib.reinforcement
+   artlib.supervised
+   artlib.topological
\ No newline at end of file
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
new file mode 100644
index 0000000..5a58eb8
--- /dev/null
+++ b/docs/source/installation.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START installation -->
+   :end-before: <!-- END installation -->
+
diff --git a/docs/source/license.rst b/docs/source/license.rst
new file mode 100644
index 0000000..d8aa21e
--- /dev/null
+++ b/docs/source/license.rst
@@ -0,0 +1,6 @@
+License
+==================================
+
+.. include:: ../../LICENSE
+   :parser: myst_parser.sphinx_
+
diff --git a/docs/source/modules.rst b/docs/source/modules.rst
new file mode 100644
index 0000000..b943c52
--- /dev/null
+++ b/docs/source/modules.rst
@@ -0,0 +1,7 @@
+artlib
+======
+
+.. toctree::
+   :maxdepth: 4
+
+   artlib
diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst
new file mode 100644
index 0000000..d9f1602
--- /dev/null
+++ b/docs/source/quick_start.rst
@@ -0,0 +1,5 @@
+.. include:: ../../README.md
+   :parser: myst_parser.sphinx_
+   :start-after: <!-- START quick-start -->
+   :end-before: <!-- END quick-start -->
+
diff --git a/docs/source/references.rst b/docs/source/references.rst
new file mode 100644
index 0000000..4a4759a
--- /dev/null
+++ b/docs/source/references.rst
@@ -0,0 +1,5 @@
+References
+==================================
+
+.. bibliography:: ../../references.bib
+   :style: unsrt
\ No newline at end of file
diff --git a/examples/comparison_of_methods.py b/examples/comparison_of_methods.py
deleted file mode 100644
index fedddca..0000000
--- a/examples/comparison_of_methods.py
+++ /dev/null
@@ -1,491 +0,0 @@
-import time
-import warnings
-from itertools import cycle, islice
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-from sklearn import datasets
-from sklearn.preprocessing import StandardScaler
-
-import artlib
-
-# ============
-# Generate datasets. We choose the size big enough to see the scalability
-# of the algorithms, but not too big to avoid too long running times
-# ============
-n_samples = 500
-seed = 30
-noisy_circles = datasets.make_circles(
-    n_samples=n_samples, factor=0.5, noise=0.05, random_state=seed, shuffle=False
-)
-noisy_moons = datasets.make_moons(n_samples=n_samples, noise=0.05, random_state=seed, shuffle=False)
-blobs = datasets.make_blobs(n_samples=n_samples, random_state=seed, shuffle=False)
-rng = np.random.RandomState(seed)
-no_structure = rng.rand(n_samples, 2), None
-
-# Anisotropicly distributed data
-random_state = 170
-X, y = datasets.make_blobs(n_samples=n_samples, random_state=random_state, shuffle=False)
-transformation = [[0.6, -0.6], [-0.4, 0.8]]
-X_aniso = np.dot(X, transformation)
-aniso = (X_aniso, y)
-
-# blobs with varied variances
-varied = datasets.make_blobs(
-    n_samples=n_samples, cluster_std=[1.0, 2.5, 0.5], random_state=random_state, shuffle=False
-)
-
-# ============
-# Set up cluster parameters
-# ============
-plt.figure(figsize=(9 * 2 + 3, 13))
-plt.subplots_adjust(
-    left=0.02, right=0.98, bottom=0.001, top=0.95, wspace=0.05, hspace=0.01
-)
-
-plot_num = 1
-
-datasets = [
-    (
-        noisy_circles,
-        {
-            "BayesianART":
-                {
-                    "rho": 9e-5,
-                    "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.95,
-
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.9,
-
-                },
-            "EllipsoidART":
-                {
-                    "rho": 0.1,
-                    "alpha": 1.0,
-                    "beta": 1.0,
-                    "r_hat": 0.9,
-                    "mu": 0.3
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.266,
-                    "alpha": 0.15,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 0.1,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.433,
-                    "alpha": 0.7,
-                    "beta": 1.0,
-                    "r_hat": 0.8
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.99,
-                    "s_init": 0.8,
-                    "lr_b": 0.2,
-                    "lr_w": 0.1,
-                    "lr_s": 0.2
-                },
-        },
-    ),
-    (
-        noisy_moons,
-        {
-            "BayesianART":
-                {
-                    "rho": 9e-5,
-                    "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.85,
-
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.78,
-
-                },
-            "EllipsoidART":
-                {
-                    "rho": 0.166,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.9,
-                    "mu": 0.4
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.33,
-                    "alpha": 0.0,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 0.133,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.2,
-                    "alpha": 0.7,
-                    "beta": 1.0,
-                    "r_hat": 0.7
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.9,
-                    "s_init": 0.9,
-                    "lr_b": 0.1,
-                    "lr_w": 0.1,
-                    "lr_s": 0.1
-                },
-        },
-    ),
-    (
-        varied,
-        {
-            "BayesianART":
-                {
-                    "rho": 3e-4,
-                    "cov_init": np.array([[1e-3, 0.0], [0.0, 1e-3]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.95,
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.88,
-
-                },
-            "EllipsoidART":
-                {
-                    "rho": 0.05,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.7,
-                    "mu": 1.0
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.4,
-                    "alpha": 0.1,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 0.08,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.2,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.3
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.933,
-                    "s_init": 0.9,
-                    "lr_b": 0.1,
-                    "lr_w": 0.1,
-                    "lr_s": 0.1
-                },
-        },
-    ),
-    (
-        aniso,
-        {
-            "BayesianART":
-                {
-                    "rho": 2e-5,
-                    "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.95,
-
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.9,
-
-                 },
-            "EllipsoidART":
-                {
-                    "rho": 5e-5,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.9,
-                    "mu": 0.9
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.5667,
-                    "alpha": 0.4,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 0.3,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.1,
-                    "alpha": 0.05,
-                    "beta": 1.0,
-                    "r_hat": 0.4667
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.933,
-                    "s_init": 0.8,
-                    "lr_b": 0.4,
-                    "lr_w": 0.1,
-                    "lr_s": 0.2
-                },
-        },
-    ),
-    (
-        blobs,
-        {
-            "BayesianART":
-                {
-                    "rho": 6e-5,
-                    "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.91,
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.82,
-
-                 },
-            "EllipsoidART":
-                {
-                    "rho": 0.01,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.6,
-                    "mu": 1.0
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.433,
-                    "alpha": 0.2,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 5e-4,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.1,
-                    "alpha": 0.05,
-                    "beta": 1.0,
-                    "r_hat": 0.433
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.95,
-                    "s_init": 0.8,
-                    "lr_b": 0.05,
-                    "lr_w": 0.1,
-                    "lr_s": 0.05
-                },
-        },
-    ),
-    (
-        no_structure,
-        {
-            "BayesianART":
-                {
-                    "rho": 2e-5,
-                    "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-                },
-            "DualVigilanceART":
-                {
-                    "FuzzyART":
-                        {
-                            "rho": 0.85,
-
-                            "alpha": 0.8,
-                            "beta": 1.0
-                        },
-                    "rho_lower_bound": 0.78,
-
-                },
-            "EllipsoidART":
-                {
-                    "rho": 0.2,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.6,
-                    "mu": 0.8
-                },
-            "FuzzyART":
-                {
-                    "rho": 0.7,
-                    "alpha": 0.0,
-                    "beta": 1.0
-                },
-            "GaussianART":
-                {
-                    "rho": 0.15,
-                    "sigma_init": np.array([0.5, 0.5]),
-                },
-            "HypersphereART":
-                {
-                    "rho": 0.7,
-                    "alpha": 0.0,
-                    "beta": 1.0,
-                    "r_hat": 0.8
-                },
-            "QuadraticNeuronART":
-                {
-                    "rho": 0.9,
-                    "s_init": 0.9,
-                    "lr_b": 0.1,
-                    "lr_w": 0.1,
-                    "lr_s": 0.1
-                },
-        },
-    ),
-]
-
-for i_dataset, (dataset, algo_params) in enumerate(datasets):
-    # update parameters with dataset-specific values
-    params = algo_params
-
-    X, y = dataset
-
-    # normalize dataset for easier parameter selection
-    X = StandardScaler().fit_transform(X)
-
-    # ============
-    # Create cluster objects
-    # ============
-    print(i_dataset)
-    hypersphere = artlib.HypersphereART(**params["HypersphereART"])
-    ellipsoid = artlib.EllipsoidART(**params["EllipsoidART"])
-    gaussian = artlib.GaussianART(**params["GaussianART"])
-    bayesian = artlib.BayesianART(**params["BayesianART"])
-    quadratic_neuron = artlib.QuadraticNeuronART(**params["QuadraticNeuronART"])
-    fuzzy = artlib.FuzzyART(**params["FuzzyART"])
-    fuzzyDV = artlib.DualVigilanceART(
-        artlib.FuzzyART(**params["DualVigilanceART"]["FuzzyART"]),
-        rho_lower_bound=params["DualVigilanceART"]["rho_lower_bound"]
-    )
-
-
-
-    clustering_algorithms = (
-        ("Hypersphere\nART", hypersphere),
-        ("Ellipsoid\nART", ellipsoid),
-        ("Gaussian\nART", gaussian),
-        ("Bayesian\nART", bayesian),
-        ("Quadratic Neuron\nART", quadratic_neuron),
-        ("FuzzyART", fuzzy),
-        ("Dual Vigilance\nFuzzyART", fuzzyDV),
-    )
-
-    for name, algorithm in clustering_algorithms:
-        t0 = time.time()
-
-        # catch warnings related to kneighbors_graph
-        with warnings.catch_warnings():
-            warnings.filterwarnings(
-                "ignore",
-                message="the number of connected components of the "
-                + "connectivity matrix is [0-9]{1,2}"
-                + " > 1. Completing it to avoid stopping the tree early.",
-                category=UserWarning,
-            )
-            warnings.filterwarnings(
-                "ignore",
-                message="Graph is not fully connected, spectral embedding"
-                + " may not work as expected.",
-                category=UserWarning,
-            )
-            X_prepared = algorithm.prepare_data(X)
-            algorithm.fit(X_prepared)
-
-        t1 = time.time()
-        if hasattr(algorithm, "labels_"):
-            y_pred = algorithm.labels_.astype(int)
-        else:
-            y_pred = algorithm.predict(X_prepared)
-
-        plt.subplot(len(datasets), len(clustering_algorithms), plot_num)
-        if i_dataset == 0:
-            plt.title(name, size=18)
-
-        colors = np.array(
-            list(
-                islice(
-                    cycle(
-                        [
-                            "#377eb8",
-                            "#ff7f00",
-                            "#4daf4a",
-                            "#f781bf",
-                            "#a65628",
-                            "#984ea3",
-                            "#999999",
-                            "#e41a1c",
-                            "#dede00",
-                        ]
-                    ),
-                    int(max(y_pred) + 1),
-                )
-            )
-        )
-        # add black color for outliers (if any)
-        colors = np.append(colors, ["#000000"])
-        plt.scatter(X[:, 0], X[:, 1], s=10, color=colors[y_pred])
-
-        plt.xlim(-2.5, 2.5)
-        plt.ylim(-2.5, 2.5)
-        plt.xticks(())
-        plt.yticks(())
-        plt.text(
-            0.99,
-            0.01,
-            ("%.2fs" % (t1 - t0)).lstrip("0"),
-            transform=plt.gca().transAxes,
-            size=15,
-            horizontalalignment="right",
-        )
-        plot_num += 1
-
-plt.savefig("img/comparison_of_elementary_methods.jpg")
\ No newline at end of file
diff --git a/examples/test_VAT.py b/examples/demo_VAT.py
similarity index 64%
rename from examples/test_VAT.py
rename to examples/demo_VAT.py
index 6caa12a..05f6919 100644
--- a/examples/test_VAT.py
+++ b/examples/demo_VAT.py
@@ -6,7 +6,13 @@
 
 
 def visualize_blobs():
-    data, target = make_blobs(n_samples=2000, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=2000,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     t0 = time.time()
@@ -14,14 +20,13 @@ def visualize_blobs():
     dt = time.time() - t0
     print("time:", dt)
 
-
     plt.figure()
-    plt.scatter(data[:,0], data[:,1], c=P, cmap="jet", s=10)
+    plt.scatter(data[:, 0], data[:, 1], c=P, cmap="jet", s=10)
 
     plt.figure()
     plt.imshow(R)
     plt.show()
 
-if __name__ == "__main__":
 
+if __name__ == "__main__":
     visualize_blobs()
diff --git a/examples/test_art1.py b/examples/demo_art1.py
similarity index 62%
rename from examples/test_art1.py
rename to examples/demo_art1.py
index ff5c333..304db79 100644
--- a/examples/test_art1.py
+++ b/examples/demo_art1.py
@@ -5,16 +5,17 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
-    data =  ( data > 0.5).astype(int)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
+    data = (data > 0.5).astype(int)
     print("Data has shape:", data.shape)
 
-
-    params = {
-        "rho": 0.7,
-        "beta": 1.0,
-        "L": 1.0
-    }
+    params = {"rho": 0.7, "L": 1.0}
     cls = ART1(**params)
 
     X = cls.prepare_data(data)
@@ -29,4 +30,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_art2.py b/examples/demo_art2.py
similarity index 83%
rename from examples/test_art2.py
rename to examples/demo_art2.py
index 9149a2b..86e9d11 100644
--- a/examples/test_art2.py
+++ b/examples/demo_art2.py
@@ -11,8 +11,15 @@
 ==================================================================
 """
 
+
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=2, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=2,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     params = {
diff --git a/examples/test_artmap.py b/examples/demo_artmap.py
similarity index 76%
rename from examples/test_artmap.py
rename to examples/demo_artmap.py
index f40b581..8ccf75d 100644
--- a/examples/test_artmap.py
+++ b/examples/demo_artmap.py
@@ -9,17 +9,15 @@ def cluster_iris():
     from sklearn.model_selection import train_test_split
     from sklearn.metrics import classification_report
     import umap.plot
+
     data, target = load_iris(return_X_y=True)
     print("Data has shape:", data.shape)
 
+    X_train, X_test, y_train, y_test = train_test_split(
+        X, target, test_size=0.33, random_state=0
+    )
 
-    X_train, X_test, y_train, y_test = train_test_split(X, target, test_size=0.33, random_state=0)
-
-    params = {
-        "rho": 0.0,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.0, "alpha": 0.0, "beta": 1.0}
     art = FuzzyART(**params)
     # params = {
     #     "rho": 0.0,
@@ -45,19 +43,23 @@ def cluster_iris():
 
     mapper = umap.UMAP().fit(X_test)
 
-    umap.plot.points(mapper, labels=y_pred, color_key_cmap='Paired', background='black')
+    umap.plot.points(
+        mapper, labels=y_pred, color_key_cmap="Paired", background="black"
+    )
     umap.plot.plt.show()
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.9,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.9, "alpha": 0.0, "beta": 1.0}
     art = FuzzyART(**params)
 
     # params = {
@@ -89,6 +91,5 @@ def main():
     cluster_blobs()
 
 
-
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/examples/test_bartmap.py b/examples/demo_bartmap.py
similarity index 65%
rename from examples/test_bartmap.py
rename to examples/demo_bartmap.py
index 8da0e97..aae8f28 100644
--- a/examples/test_bartmap.py
+++ b/examples/demo_bartmap.py
@@ -7,23 +7,19 @@
 def cluster_checkerboard():
     n_clusters = (4, 3)
     data, rows, columns = make_checkerboard(
-        shape=(300, 300), n_clusters=n_clusters, noise=10, shuffle=False, random_state=42
+        shape=(300, 300),
+        n_clusters=n_clusters,
+        noise=10,
+        shuffle=False,
+        random_state=42,
     )
     print("Data has shape:", data.shape)
 
-    params_a = {
-        "rho": 0.6,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
-    params_b = {
-        "rho": 0.6,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params_a = {"rho": 0.6, "alpha": 0.0, "beta": 1.0}
+    params_b = {"rho": 0.6, "alpha": 0.0, "beta": 1.0}
     art_a = FuzzyART(**params_a)
     art_b = FuzzyART(**params_b)
-    cls = BARTMAP(art_a, art_b, eta=-1.)
+    cls = BARTMAP(art_a, art_b, eta=-1.0)
 
     X = data
 
diff --git a/examples/test_bayesian_art.py b/examples/demo_bayesian_art.py
similarity index 80%
rename from examples/test_bayesian_art.py
rename to examples/demo_bayesian_art.py
index 86df104..6ba02c5 100644
--- a/examples/test_bayesian_art.py
+++ b/examples/demo_bayesian_art.py
@@ -15,7 +15,13 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     params = {
@@ -27,7 +33,6 @@ def cluster_blobs():
     X = cls.prepare_data(data)
     print("Prepared data has shape:", X.shape)
 
-
     y = cls.fit_predict(X)
 
     print(f"{cls.n_clusters} clusters found")
@@ -37,4 +42,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/demo_convex_hull_art.py b/examples/demo_convex_hull_art.py
new file mode 100644
index 0000000..efa7f77
--- /dev/null
+++ b/examples/demo_convex_hull_art.py
@@ -0,0 +1,41 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+import numpy as np
+
+from artlib.experimental.HullART import HullART, plot_polygon
+from alphashape import alphashape
+
+
+def cluster_blobs():
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
+    print("Data has shape:", data.shape)
+
+    params = {"rho": 0.6, "alpha": 1e-3, "alpha_hat": 1.0}
+    cls = HullART(**params)
+
+    X = cls.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+def test():
+    points = np.array(
+        [(0.0, 0.0), (0.0, 1.0), (1.0,1.0), (1.0, 0.0)]
+    )
+    x = alphashape(points, alpha=1.0)
+    print(x.length)
+
+if __name__ == "__main__":
+    cluster_blobs()
+    # test()
\ No newline at end of file
diff --git a/examples/test_cvi_art.py b/examples/demo_cvi_art.py
similarity index 76%
rename from examples/test_cvi_art.py
rename to examples/demo_cvi_art.py
index d308443..b8d67b5 100644
--- a/examples/test_cvi_art.py
+++ b/examples/demo_cvi_art.py
@@ -5,7 +5,13 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     params = {
@@ -28,4 +34,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_dual_vigilance_art.py b/examples/demo_dual_vigilance_art.py
similarity index 70%
rename from examples/test_dual_vigilance_art.py
rename to examples/demo_dual_vigilance_art.py
index 578e121..eb83699 100644
--- a/examples/test_dual_vigilance_art.py
+++ b/examples/demo_dual_vigilance_art.py
@@ -5,14 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.85,
-        "alpha": 0.8,
-        "beta": 1.0
-    }
+    params = {"rho": 0.85, "alpha": 0.8, "beta": 1.0}
     base_art = FuzzyART(**params)
     cls = DualVigilanceART(base_art, 0.78)
 
@@ -29,4 +31,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_ellipsoid_art.py b/examples/demo_ellipsoid_art.py
similarity index 62%
rename from examples/test_ellipsoid_art.py
rename to examples/demo_ellipsoid_art.py
index bf1b2b0..28f447a 100644
--- a/examples/test_ellipsoid_art.py
+++ b/examples/demo_ellipsoid_art.py
@@ -5,16 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.01,
-        "alpha": 0.0,
-        "beta": 1.0,
-        "r_hat": 0.65,
-        "mu": 0.8
-    }
+    params = {"rho": 0.01, "alpha": 0.0, "beta": 1.0, "r_hat": 0.65, "mu": 0.8}
     cls = EllipsoidART(**params)
 
     X = cls.prepare_data(data)
@@ -29,4 +29,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_fusion_art.py b/examples/demo_fusion_art.py
similarity index 59%
rename from examples/test_fusion_art.py
rename to examples/demo_fusion_art.py
index 01c6747..ce9b858 100644
--- a/examples/test_fusion_art.py
+++ b/examples/demo_fusion_art.py
@@ -6,20 +6,24 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    data_channel_a = data[:,0].reshape((-1,1))
-    data_channel_b = data[:,1].reshape((-1,1))
+    data_channel_a = data[:, 0].reshape((-1, 1))
+    data_channel_b = data[:, 1].reshape((-1, 1))
 
-    params = {
-        "rho": 0.5,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.5, "alpha": 0.0, "beta": 1.0}
     art_a = FuzzyART(**params)
     art_b = FuzzyART(**params)
-    cls = FusionART([art_a, art_b], gamma_values=np.array([0.5, 0.5]), channel_dims=[2,2])
+    cls = FusionART(
+        [art_a, art_b], gamma_values=np.array([0.5, 0.5]), channel_dims=[2, 2]
+    )
 
     X_channel_a = art_a.prepare_data(data_channel_a)
     X_channel_b = art_b.prepare_data(data_channel_b)
@@ -36,4 +40,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_fuzzy_art.py b/examples/demo_fuzzy_art.py
similarity index 65%
rename from examples/test_fuzzy_art.py
rename to examples/demo_fuzzy_art.py
index 29be523..e647638 100644
--- a/examples/test_fuzzy_art.py
+++ b/examples/demo_fuzzy_art.py
@@ -5,14 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.5,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.5, "alpha": 0.0, "beta": 1.0}
     cls = FuzzyART(**params)
 
     X = cls.prepare_data(data)
@@ -27,4 +29,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_gaussian_art.py b/examples/demo_gaussian_art.py
similarity index 75%
rename from examples/test_gaussian_art.py
rename to examples/demo_gaussian_art.py
index f384a2f..ae8d1cd 100644
--- a/examples/test_gaussian_art.py
+++ b/examples/demo_gaussian_art.py
@@ -6,7 +6,13 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     params = {
@@ -27,4 +33,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_grid_search_cv.py b/examples/demo_grid_search_cv.py
similarity index 81%
rename from examples/test_grid_search_cv.py
rename to examples/demo_grid_search_cv.py
index a63fb58..418ae6f 100644
--- a/examples/test_grid_search_cv.py
+++ b/examples/demo_grid_search_cv.py
@@ -9,18 +9,15 @@ def grid_search_blobs():
     data, target = load_iris(return_X_y=True)
     print("Data has shape:", data.shape)
 
-    base_params = {
-        "rho": 0.3,
-        "alpha": 0.0,
-        "beta": 1.0,
-        "r_hat": 0.8
-    }
+    base_params = {"rho": 0.3, "alpha": 0.0, "beta": 1.0, "r_hat": 0.8}
     art = HypersphereART(**base_params)
 
     X = art.prepare_data(data)
     print("Prepared data has shape:", X.shape)
 
-    X_train, X_test, y_train, y_test = train_test_split(X, target, test_size=0.33, random_state=0)
+    X_train, X_test, y_train, y_test = train_test_split(
+        X, target, test_size=0.33, random_state=0
+    )
 
     cls = SimpleARTMAP(art)
     # print(cls.get_params(deep=False))
@@ -30,7 +27,7 @@ def grid_search_blobs():
         "module_a__rho": [0.0, 0.25, 0.5, 0.7, 0.9],
         "module_a__alpha": [0.0],
         "module_a__beta": [1.0],
-        "module_a__r_hat": [0.8]
+        "module_a__r_hat": [0.8],
     }
 
     grid = GridSearchCV(cls, param_grid, refit=True, verbose=3, n_jobs=1)
diff --git a/examples/test_grid_search_cv_elementary.py b/examples/demo_grid_search_cv_elementary.py
similarity index 79%
rename from examples/test_grid_search_cv_elementary.py
rename to examples/demo_grid_search_cv_elementary.py
index c6b1e13..3781f59 100644
--- a/examples/test_grid_search_cv_elementary.py
+++ b/examples/demo_grid_search_cv_elementary.py
@@ -15,7 +15,13 @@
 
 
 def grid_search_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     base_params = {
@@ -41,16 +47,7 @@ def grid_search_blobs():
         verbose=3,
         n_jobs=1,
         scoring="adjusted_rand_score",
-        cv=[
-            (
-                np.array(
-                    list(range(len(X)))
-                ),
-                np.array(
-                    list(range(len(X)))
-                )
-            )
-        ]
+        cv=[(np.array(list(range(len(X)))), np.array(list(range(len(X)))))],
     )
 
     grid.fit(X, target)
diff --git a/examples/test_hypersphere_art.py b/examples/demo_hypersphere_art.py
similarity index 64%
rename from examples/test_hypersphere_art.py
rename to examples/demo_hypersphere_art.py
index 99c7a36..be49324 100644
--- a/examples/test_hypersphere_art.py
+++ b/examples/demo_hypersphere_art.py
@@ -5,15 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.5,
-        "alpha": 0.0,
-        "beta": 1.0,
-        "r_hat": 0.8
-    }
+    params = {"rho": 0.5, "alpha": 0.0, "beta": 1.0, "r_hat": 0.8}
     cls = HypersphereART(**params)
 
     X = cls.prepare_data(data)
@@ -28,4 +29,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_icvi_art.py b/examples/demo_icvi_art.py
similarity index 69%
rename from examples/test_icvi_art.py
rename to examples/demo_icvi_art.py
index 3919708..aef19f2 100644
--- a/examples/test_icvi_art.py
+++ b/examples/demo_icvi_art.py
@@ -5,14 +5,20 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
     params = {
         "rho": 0.0,
         "alpha": 0.0,
         "beta": 1.0,
-        "validity": iCVIFuzzyART.CALINSKIHARABASZ
+        "validity": iCVIFuzzyART.CALINSKIHARABASZ,
     }
     cls = iCVIFuzzyART(**params)
 
@@ -28,4 +34,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/demo_make_gif.py b/examples/demo_make_gif.py
new file mode 100644
index 0000000..687e4a0
--- /dev/null
+++ b/examples/demo_make_gif.py
@@ -0,0 +1,33 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import FuzzyART
+
+
+def cluster_blobs():
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
+    print("Data has shape:", data.shape)
+
+    params = {"rho": 0.5, "alpha": 0.0, "beta": 1.0}
+    cls = FuzzyART(**params)
+
+    X = cls.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    cls = cls.fit_gif(X, filename="fit_gif_FuzzyART.gif", n_cluster_estimate=3)
+    y = cls.labels_
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
diff --git a/examples/test_quadratic_neuron_art.py b/examples/demo_quadratic_neuron_art.py
similarity index 63%
rename from examples/test_quadratic_neuron_art.py
rename to examples/demo_quadratic_neuron_art.py
index c90cefb..b283205 100644
--- a/examples/test_quadratic_neuron_art.py
+++ b/examples/demo_quadratic_neuron_art.py
@@ -5,16 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.9,
-        "s_init": 0.9,
-        "lr_b": 0.1,
-        "lr_w": 0.1,
-        "lr_s": 0.1
-    }
+    params = {"rho": 0.9, "s_init": 0.9, "lr_b": 0.1, "lr_w": 0.1, "lr_s": 0.1}
     cls = QuadraticNeuronART(**params)
 
     X = cls.prepare_data(data)
@@ -29,4 +29,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/test_regression.py b/examples/demo_regression.py
similarity index 73%
rename from examples/test_regression.py
rename to examples/demo_regression.py
index d4c1a64..bc01467 100644
--- a/examples/test_regression.py
+++ b/examples/demo_regression.py
@@ -4,25 +4,22 @@
 
 from artlib import FuzzyART, ARTMAP, FusionART, normalize
 
+
 def make_data():
-    x_ = np.arange(0.0,4.0, 0.01)
-    x = x_+0.5
-    y = np.sin(x)+np.cos(x**2)+np.log(x**3)+np.cos(x)*np.log(x**2)
+    x_ = np.arange(0.0, 4.0, 0.01)
+    x = x_ + 0.5
+    y = np.sin(x) + np.cos(x**2) + np.log(x**3) + np.cos(x) * np.log(x**2)
 
     x_norm, _, _ = normalize(x)
     y_norm, _, _ = normalize(y)
-    return x_norm.reshape((-1,1)), y_norm.reshape((-1,1))
+    return x_norm.reshape((-1, 1)), y_norm.reshape((-1, 1))
 
 
 def fit_regression_ARTMAP():
     X_, y_ = make_data()
     print("Data has shape:", X_.shape)
 
-    params = {
-        "rho": 0.95,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.95, "alpha": 0.0, "beta": 1.0}
     art_a = FuzzyART(**params)
     art_b = FuzzyART(**params)
 
@@ -38,20 +35,17 @@ def fit_regression_ARTMAP():
 
     y_pred = cls.predict_regression(X)
 
-    plt.plot(X_, y_, 'r-', label="original")
+    plt.plot(X_, y_, "r-", label="original")
     plt.plot(X_, y_pred, "b-", label="ARTMAP")
     plt.legend()
     plt.show()
 
+
 def fit_regression_FusionART():
     X_, y_ = make_data()
     print("Data has shape:", X_.shape)
 
-    params = {
-        "rho": 0.95,
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    params = {"rho": 0.95, "alpha": 0.0, "beta": 1.0}
     art_a = FuzzyART(**params)
     art_b = FuzzyART(**params)
 
@@ -59,7 +53,9 @@ def fit_regression_FusionART():
     y = art_b.prepare_data(y_)
     print("Prepared data has shape:", X.shape)
 
-    cls = FusionART([art_a, art_b], gamma_values=np.array([0.5, 0.5]), channel_dims=[2, 2])
+    cls = FusionART(
+        [art_a, art_b], gamma_values=np.array([0.5, 0.5]), channel_dims=[2, 2]
+    )
 
     Xy = cls.join_channel_data(channel_data=[X, y])
 
@@ -71,16 +67,16 @@ def fit_regression_FusionART():
 
     y_pred = cls.predict_regression(X_no_y, target_channels=[-1])
 
-    plt.plot(X_, y_, 'r-', label="original")
+    plt.plot(X_, y_, "r-", label="original")
     plt.plot(X_, y_pred, "b-", label="FusionART")
     plt.legend()
     plt.show()
 
+
 def main():
     # fit_regression_ARTMAP()
     fit_regression_FusionART()
 
 
-
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/examples/test_reinforcement_learning.py b/examples/demo_reinforcement_learning.py
similarity index 70%
rename from examples/test_reinforcement_learning.py
rename to examples/demo_reinforcement_learning.py
index faa0172..2051768 100644
--- a/examples/test_reinforcement_learning.py
+++ b/examples/demo_reinforcement_learning.py
@@ -6,6 +6,7 @@
 import matplotlib.pyplot as plt
 from copy import deepcopy
 
+
 # only works with Fuzzy ART based FALCON
 def prune_clusters(cls):
     # get existing state and action weights
@@ -19,24 +20,29 @@ def prune_clusters(cls):
     # identify unique combinations
     unique_combined, indices = np.unique(combined, axis=0, return_inverse=True)
     # get mean reward prediction for each unique state-action pair
-    unique_rewards = np.array([reward[indices == i, :].mean() for i in range(len(unique_combined))])
+    unique_rewards = np.array(
+        [reward[indices == i, :].mean() for i in range(len(unique_combined))]
+    )
 
     # split unique state-action pairs
-    unique_states = unique_combined[:, :state.shape[1]]
-    unique_actions = unique_combined[:, state.shape[1]:]
+    unique_states = unique_combined[:, : state.shape[1]]
+    unique_actions = unique_combined[:, state.shape[1] :]
 
     # update model to only have unique state-action pairs and their average rewards
     cls.fusion_art.modules[0].W = [row for row in unique_states]
     cls.fusion_art.modules[1].W = [row for row in unique_actions]
-    cls.fusion_art.modules[2].W = [row for row in compliment_code(unique_rewards.reshape((-1, 1)))]
+    cls.fusion_art.modules[2].W = [
+        row for row in compliment_code(unique_rewards.reshape((-1, 1)))
+    ]
 
     return cls
 
+
 def update_FALCON(records, cls, shrink_ratio):
     # convert records into arrays
-    states = np.array(records["states"]).reshape((-1,1))
-    actions = np.array(records["actions"]).reshape((-1,1))
-    rewards = np.array(records["rewards"]).reshape((-1,1))
+    states = np.array(records["states"]).reshape((-1, 1))
+    actions = np.array(records["actions"]).reshape((-1, 1))
+    rewards = np.array(records["rewards"]).reshape((-1, 1))
 
     # compliment code states and actions
     states_cc = compliment_code(states)
@@ -56,17 +62,33 @@ def update_FALCON(records, cls, shrink_ratio):
     # fit FALCON to data
     # data = cls.fusion_art.join_channel_data([states_fit, actions_fit, sarsa_rewards_fit])
     # cls.fusion_art = cls.fusion_art.partial_fit(data)
-    cls = cls.partial_fit(states_cc, actions_cc, rewards_cc, single_sample_reward=1.0)
+    cls = cls.partial_fit(
+        states_cc, actions_cc, rewards_cc, single_sample_reward=1.0
+    )
     for m in range(3):
-        cls.fusion_art.modules[m] = cls.fusion_art.modules[m].shrink_clusters(shrink_ratio)
+        cls.fusion_art.modules[m] = cls.fusion_art.modules[m].shrink_clusters(
+            shrink_ratio
+        )
 
     return cls
 
-def training_cycle(cls, epochs, steps, sarsa_alpha, sarsa_gamma, render_mode=None, shrink_ratio=0.1, explore_rate=0.0, checkpoint_frequency=50, early_stopping_reward=-20):
+
+def training_cycle(
+    cls,
+    epochs,
+    steps,
+    sarsa_alpha,
+    sarsa_gamma,
+    render_mode=None,
+    shrink_ratio=0.1,
+    explore_rate=0.0,
+    checkpoint_frequency=50,
+    early_stopping_reward=-20,
+):
     # create the environment
-    env = gym.make('CliffWalking-v0', render_mode=render_mode)
+    env = gym.make("CliffWalking-v0", render_mode=render_mode)
     # define some constants
-    ACTION_SPACE = np.array([[0], [1.], [2.], [3.]])
+    ACTION_SPACE = np.array([[0], [1.0], [2.0], [3.0]])
     STATE_MAX = 47
     ACTION_MAX = 3
     REWARD_MAX = 150
@@ -87,11 +109,15 @@ def training_cycle(cls, epochs, steps, sarsa_alpha, sarsa_gamma, render_mode=Non
         past_states = []
         for _ in range(steps):
             # get an action
-            observation_cc = compliment_code(np.array([n_observation]).reshape(1, -1))
+            observation_cc = compliment_code(
+                np.array([n_observation]).reshape(1, -1)
+            )
             if np.random.random() < explore_rate:
                 action = int(np.random.choice(ACTION_SPACE.flatten()))
             else:
-                cls_action = cls.get_action(observation_cc, action_space=ACTION_SPACE, optimality="min")
+                cls_action = cls.get_action(
+                    observation_cc, action_space=ACTION_SPACE, optimality="min"
+                )
                 action = int(cls_action[0])
             # normalize state and action
             n_observation = observation / STATE_MAX
@@ -121,14 +147,16 @@ def training_cycle(cls, epochs, steps, sarsa_alpha, sarsa_gamma, render_mode=Non
         # train FALCON
         cls = update_FALCON(records, cls, shrink_ratio)
         # record sum of rewards generated during this epoch
-        reward_history.append(-sum(records["rewards"])*REWARD_MAX)
+        reward_history.append(-sum(records["rewards"]) * REWARD_MAX)
 
         # if this isnt random exploration
         if explore_rate < 1.0:
             # see if we should save a checkpoint
-            if (e+1)%checkpoint_frequency == 0 or e == epochs-1:
+            if (e + 1) % checkpoint_frequency == 0 or e == epochs - 1:
                 # test model
-                cls, test_reward_history = demo_cycle(cls, 1, steps, render_mode=None)
+                cls, test_reward_history = demo_cycle(
+                    cls, 1, steps, render_mode=None
+                )
                 # check if our current model is better than the best previous model
                 if test_reward_history[0] >= best_reward or best_cls is None:
                     # same a checkpoint
@@ -137,13 +165,13 @@ def training_cycle(cls, epochs, steps, sarsa_alpha, sarsa_gamma, render_mode=Non
                     # check early stopping condition
                     if best_reward > early_stopping_reward:
                         # show current best reward on progress bar
-                        pbar.set_postfix({'Best Reward': best_reward})
+                        pbar.set_postfix({"Best Reward": best_reward})
                         return cls, reward_history
                 else:
                     # restore previous best model
                     cls = deepcopy(best_cls)
             # show current best reward on progress bar
-            pbar.set_postfix({'Best Reward': best_reward})
+            pbar.set_postfix({"Best Reward": best_reward})
 
     env.close()
     return cls, reward_history
@@ -151,9 +179,9 @@ def training_cycle(cls, epochs, steps, sarsa_alpha, sarsa_gamma, render_mode=Non
 
 def demo_cycle(cls, epochs, steps, render_mode=None):
     # create the environment
-    env = gym.make('CliffWalking-v0', render_mode=render_mode)
+    env = gym.make("CliffWalking-v0", render_mode=render_mode)
     # define some constants
-    ACTION_SPACE = np.array([[0], [1.], [2.], [3.]])
+    ACTION_SPACE = np.array([[0], [1.0], [2.0], [3.0]])
     STATE_MAX = 47
     ACTION_MAX = 3
     REWARD_MAX = 150
@@ -167,10 +195,13 @@ def demo_cycle(cls, epochs, steps, render_mode=None):
         records = {"states": [], "actions": [], "rewards": []}
         past_states = []
         for _ in range(steps):
-
             # get an action
-            observation_cc = compliment_code(np.array([n_observation]).reshape(1, -1))
-            cls_action = cls.get_action(observation_cc, action_space=ACTION_SPACE, optimality="min")
+            observation_cc = compliment_code(
+                np.array([n_observation]).reshape(1, -1)
+            )
+            cls_action = cls.get_action(
+                observation_cc, action_space=ACTION_SPACE, optimality="min"
+            )
             action = int(cls_action[0])
 
             # normalize state and action
@@ -198,7 +229,7 @@ def demo_cycle(cls, epochs, steps, render_mode=None):
             if terminated or truncated or reward == -100:
                 break
         # record sum of rewards generated during this epoch
-        reward_history.append(-sum(records["rewards"])*REWARD_MAX)
+        reward_history.append(-sum(records["rewards"]) * REWARD_MAX)
 
     env.close()
     return cls, reward_history
@@ -206,7 +237,7 @@ def demo_cycle(cls, epochs, steps, render_mode=None):
 
 def max_up_to_each_element(lst):
     max_list = []
-    current_max = float('-inf')  # Start with the smallest possible value
+    current_max = float("-inf")  # Start with the smallest possible value
     for num in lst:
         current_max = max(current_max, num)
         max_list.append(current_max)
@@ -216,18 +247,46 @@ def max_up_to_each_element(lst):
 def train_FALCON():
     # define training regimen
     training_regimen = [
-        {"name": "random", "epochs": 1000, "shrink_ratio": 0.3, "gamma": 0.0, "explore_rate": 1.0, "render_mode": None},
-        {"name": "explore 33%", "epochs": 500, "shrink_ratio": 0.3, "gamma": 0.2, "explore_rate": 0.333, "render_mode": None},
-        {"name": "explore 20%", "epochs": 500, "shrink_ratio": 0.3, "gamma": 0.2, "explore_rate": 0.20, "render_mode": None},
-        {"name": "explore 5%", "epochs": 1000, "shrink_ratio": 0.3, "gamma": 0.2, "explore_rate": 0.05, "render_mode": None},
+        {
+            "name": "random",
+            "epochs": 1000,
+            "shrink_ratio": 0.3,
+            "gamma": 0.0,
+            "explore_rate": 1.0,
+            "render_mode": None,
+        },
+        {
+            "name": "explore 33%",
+            "epochs": 500,
+            "shrink_ratio": 0.3,
+            "gamma": 0.2,
+            "explore_rate": 0.333,
+            "render_mode": None,
+        },
+        {
+            "name": "explore 20%",
+            "epochs": 500,
+            "shrink_ratio": 0.3,
+            "gamma": 0.2,
+            "explore_rate": 0.20,
+            "render_mode": None,
+        },
+        {
+            "name": "explore 5%",
+            "epochs": 1000,
+            "shrink_ratio": 0.3,
+            "gamma": 0.2,
+            "explore_rate": 0.05,
+            "render_mode": None,
+        },
     ]
     MAX_STEPS = 25
     SARSA_ALPHA = 1.0
 
     # define parameters for state, action, and reward modules
-    art_state = FuzzyART(rho=0.99,alpha=0.01, beta=1.0)
-    art_action = FuzzyART(rho=0.99,alpha=0.01, beta=1.0)
-    art_reward = FuzzyART(rho=0.0,alpha=0.01, beta=1.0)
+    art_state = FuzzyART(rho=0.99, alpha=0.01, beta=1.0)
+    art_action = FuzzyART(rho=0.99, alpha=0.01, beta=1.0)
+    art_reward = FuzzyART(rho=0.0, alpha=0.01, beta=1.0)
     # instantiate FALCON
     cls = TD_FALCON(art_state, art_action, art_reward, channel_dims=[2, 2, 2])
     # record rewards for each epoch
@@ -244,13 +303,15 @@ def train_FALCON():
             sarsa_gamma=regimen["gamma"],
             render_mode=regimen["render_mode"],
             shrink_ratio=regimen["shrink_ratio"],
-            explore_rate=regimen["explore_rate"]
+            explore_rate=regimen["explore_rate"],
         )
         all_rewards.extend(reward_history)
-        print("MAX REWARD:",max(reward_history))
+        print("MAX REWARD:", max(reward_history))
 
     # demo learned policy
-    cls, reward_history = demo_cycle(cls, epochs=2, steps=25, render_mode="human")
+    cls, reward_history = demo_cycle(
+        cls, epochs=2, steps=25, render_mode="human"
+    )
     print(reward_history)
     all_rewards.extend(reward_history)
     max_rewards = max_up_to_each_element(all_rewards)
@@ -264,7 +325,8 @@ def train_FALCON():
     plt.title("Rewards over Time")
     plt.show()
 
+
 if __name__ == "__main__":
     # This takes approximately 3 minutes
     np.random.seed(42)
-    train_FALCON()
\ No newline at end of file
+    train_FALCON()
diff --git a/examples/test_seq_art.py b/examples/demo_seq_art.py
similarity index 94%
rename from examples/test_seq_art.py
rename to examples/demo_seq_art.py
index 1828c77..c9369cf 100644
--- a/examples/test_seq_art.py
+++ b/examples/demo_seq_art.py
@@ -18,12 +18,11 @@
     "substitution",
 ]
 
+
 def cluster_sequences():
     X = np.array(sequences, dtype=object)
 
-    params = {
-        "rho": -0.5
-    }
+    params = {"rho": -0.5}
     cls = SeqART(**params)
 
     y = cls.fit_predict(X)
@@ -41,5 +40,3 @@ def cluster_sequences():
 
 if __name__ == "__main__":
     cluster_sequences()
-
-
diff --git a/examples/test_smart.py b/examples/demo_smart.py
similarity index 73%
rename from examples/test_smart.py
rename to examples/demo_smart.py
index 11f76cc..a6ad002 100644
--- a/examples/test_smart.py
+++ b/examples/demo_smart.py
@@ -5,13 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    base_params = {
-        "alpha": 0.0,
-        "beta": 1.0
-    }
+    base_params = {"alpha": 0.0, "beta": 1.0}
     rho_values = [0.5, 0.85, 0.9]
     cls = SMART(FuzzyART, rho_values, base_params)
 
@@ -29,6 +32,5 @@ def main():
     cluster_blobs()
 
 
-
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/examples/test_topo_art.py b/examples/demo_topo_art.py
similarity index 74%
rename from examples/test_topo_art.py
rename to examples/demo_topo_art.py
index 3e7bda7..b25d2f5 100644
--- a/examples/test_topo_art.py
+++ b/examples/demo_topo_art.py
@@ -5,14 +5,16 @@
 
 
 def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
+    data, target = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
     print("Data has shape:", data.shape)
 
-    params = {
-        "rho": 0.4,
-        "alpha": 0.8,
-        "beta": 1.0
-    }
+    params = {"rho": 0.4, "alpha": 0.8, "beta": 1.0}
     base_art = FuzzyART(**params)
 
     X = base_art.prepare_data(data)
@@ -33,4 +35,4 @@ def cluster_blobs():
 
 
 if __name__ == "__main__":
-    cluster_blobs()
\ No newline at end of file
+    cluster_blobs()
diff --git a/examples/optimization_of_methods.py b/examples/optimization_of_methods.py
deleted file mode 100644
index 5dff220..0000000
--- a/examples/optimization_of_methods.py
+++ /dev/null
@@ -1,240 +0,0 @@
-import time
-import warnings
-from itertools import cycle, islice
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-from sklearn import datasets
-from sklearn.preprocessing import StandardScaler
-from sklearn.model_selection import GridSearchCV
-
-import artlib
-
-# ============
-# Generate datasets. We choose the size big enough to see the scalability
-# of the algorithms, but not too big to avoid too long running times
-# ============
-n_samples = 500
-seed = 30
-noisy_circles = datasets.make_circles(
-    n_samples=n_samples, factor=0.5, noise=0.05, random_state=seed, shuffle=False
-)
-noisy_moons = datasets.make_moons(n_samples=n_samples, noise=0.05, random_state=seed, shuffle=False)
-blobs = datasets.make_blobs(n_samples=n_samples, random_state=seed, shuffle=False)
-rng = np.random.RandomState(seed)
-no_structure = rng.rand(n_samples, 2), None
-
-# Anisotropicly distributed data
-random_state = 170
-X, y = datasets.make_blobs(n_samples=n_samples, random_state=random_state, shuffle=False)
-transformation = [[0.6, -0.6], [-0.4, 0.8]]
-X_aniso = np.dot(X, transformation)
-aniso = (X_aniso, y)
-
-# blobs with varied variances
-varied = datasets.make_blobs(
-    n_samples=n_samples, cluster_std=[1.0, 2.5, 0.5], random_state=random_state, shuffle=False
-)
-
-# ============
-# Set up cluster parameters
-# ============
-
-init_params = {
-    "BayesianART":
-        {
-            "rho": 2e-5,
-            "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
-        },
-    "DualVigilanceFuzzyART":
-        {
-            "FuzzyART":
-                {
-                    "rho": 0.95,
-
-                    "alpha": 0.8,
-                    "beta": 1.0
-                },
-            "rho_lower_bound": 0.5,
-            "base_module": artlib.FuzzyART(0.0, 0.0, 1.0),
-        },
-    "EllipsoidART":
-        {
-            "rho": 0.2,
-            "alpha": 0.0,
-            "beta": 1.0,
-            "r_hat": 0.6,
-            "mu": 0.8
-        },
-    "FuzzyART":
-        {
-            "rho": 0.7,
-            "alpha": 0.0,
-            "beta": 1.0
-        },
-    "GaussianART":
-        {
-            "rho": 0.15,
-            "sigma_init": np.array([0.5, 0.5]),
-        },
-    "HypersphereART":
-        {
-            "rho": 0.6,
-            "alpha": 0.0,
-            "beta": 1.0,
-            "r_hat": 0.8
-        },
-    "QuadraticNeuronART":
-        {
-            "rho": 0.99,
-            "s_init": 0.8,
-            "lr_b": 0.2,
-            "lr_w": 0.1,
-            "lr_s": 0.2
-        },
-}
-
-rho_range = np.linspace(0.1, 1.0, 28)
-rho_range[-1] = 0.99
-alpha_range = np.linspace(0.0, 1.0, 21)
-
-
-
-param_grids = {
-    "BayesianART":
-        {
-            "rho": [1e-5, 2e-5, 3e-5, 4e-5, 5e-5, 6e-5, 7e-5, 8e-5, 9e-5],
-            "cov_init": [np.array([[1e-4, 0.0], [0.0, 1e-4]])],
-        },
-    "DualVigilanceFuzzyART":
-        {
-            "base_module": [artlib.FuzzyART(0.1, 0.0, 1.0)],
-            "base_module__rho": rho_range,
-            "base_module__alpha": alpha_range,
-            "base_module__beta": [1.0],
-            "rho_lower_bound": rho_range,
-
-        },
-    "EllipsoidART":
-        {
-            "rho": rho_range,
-            "alpha": alpha_range,
-            "beta": [1.0],
-            "r_hat": np.linspace(0.1, 0.9, 17),
-            "mu": np.linspace(0.1, 1.0, 10)
-        },
-    "FuzzyART":
-        {
-            "rho": rho_range,
-            "alpha":alpha_range,
-            "beta": [1.0]
-        },
-    "GaussianART":
-        {
-            "rho": rho_range,
-            "sigma_init": [np.array([0.5, 0.5])],
-        },
-    "HypersphereART":
-        {
-            "rho": rho_range,
-            "alpha": alpha_range,
-            "beta": [1.0],
-            "r_hat": np.linspace(0.1, 0.8, 22),
-        },
-    "QuadraticNeuronART":
-        {
-            "rho": rho_range,
-            "s_init": np.linspace(0.1, 1.0, 10),
-            "lr_b": np.linspace(0.1, 0.6, 6),
-            "lr_w": np.linspace(0.1, 0.6, 6),
-            "lr_s": np.linspace(0.1, 0.6, 6)
-        },
-}
-
-plot_num = 1
-
-datasets = [
-    (
-        "noisy_circles",
-        noisy_circles,
-    ),
-    (
-        "noisy_moons",
-        noisy_moons,
-    ),
-    (
-        "varied",
-        varied,
-    ),
-    (
-        "aniso",
-        aniso,
-    ),
-    (
-        "blobs",
-        blobs,
-    ),
-    (
-        "no_structure",
-        no_structure,
-    )
-]
-
-for i_dataset, (dataset_name, dataset) in enumerate(datasets):
-
-    X, y = dataset
-
-    # normalize dataset for easier parameter selection
-    X = StandardScaler().fit_transform(X)
-
-    # ============
-    # Create cluster objects
-    # ============
-    print(dataset_name)
-    hypersphere = artlib.HypersphereART(**init_params["HypersphereART"])
-    ellipsoid = artlib.EllipsoidART(**init_params["EllipsoidART"])
-    gaussian = artlib.GaussianART(**init_params["GaussianART"])
-    bayesian = artlib.BayesianART(**init_params["BayesianART"])
-    quadratic_neuron = artlib.QuadraticNeuronART(**init_params["QuadraticNeuronART"])
-    fuzzy = artlib.FuzzyART(**init_params["FuzzyART"])
-    fuzzyDV = artlib.DualVigilanceART(
-        artlib.FuzzyART(**init_params["FuzzyART"]),
-        rho_lower_bound=init_params["DualVigilanceFuzzyART"]["rho_lower_bound"]
-    )
-
-
-    clustering_algorithms = (
-        ("DualVigilanceFuzzyART", fuzzyDV),
-        ("HypersphereART", hypersphere),
-        ("EllipsoidART", ellipsoid),
-        ("GaussianART", gaussian),
-        ("BayesianART", bayesian),
-        ("QuadraticNeuronART", quadratic_neuron),
-        ("FuzzyART", fuzzy),
-    )
-
-    for name, algorithm in clustering_algorithms:
-        print("\t", name)
-        grid = GridSearchCV(
-            algorithm,
-            param_grids[name],
-            refit=False,
-            verbose=0,
-            n_jobs=-1,
-            scoring="adjusted_rand_score",
-            cv=[
-                (
-                    np.array(
-                        list(range(len(X)))
-                    ),
-                    np.array(
-                        list(range(len(X)))
-                    )
-                )
-            ]
-        )
-        X_prepared = algorithm.prepare_data(X)
-        grid.fit(X_prepared, y)
-        print("\tBest parameters:")
-        print("\t\t", grid.best_params_)
diff --git a/paper.md b/paper.md
new file mode 100644
index 0000000..ffbae9f
--- /dev/null
+++ b/paper.md
@@ -0,0 +1,414 @@
+---
+title: 'Adaptive Resonance Lib: A Python package for Adaptive Resonance Theory (ART) models'
+tags:
+  - python
+  - clustering
+  - classification
+  - regression
+  - reinforcement learning
+  - machine learning
+authors:
+  - name: Niklas M. Melton
+    corresponding: true
+    orcid: 0000-0001-9625-7086
+    affiliation: 1
+  - name: Dustin Tanksley
+    orcid: 0000-0002-1677-0304
+    affiliation: 1
+  - name: Donald C. Wunsch II
+    orcid: 0000-0002-9726-9051
+    affiliation: 1
+affiliations:
+ - name: Missouri University of Science and Technology, USA
+   index: 1
+   ror: 00scwqd12
+date: 18 October 2024
+bibliography: references.bib
+---
+
+# Summary
+
+The Adaptive Resonance Theory Library (**artlib**) is a Python library that
+implements a wide range of Adaptive Resonance Theory (ART) algorithms. **artlib**
+currently supports eight elementary ART models and 11 compound ART models. These
+models can be applied to tasks such as unsupervised clustering, supervised
+classification, regression, and reinforcement learning [@da2019survey]. This library
+provides an extensible and modular framework where users can integrate custom models
+or extend current implementations, allowing for experimentation with existing and
+novel machine learning techniques.
+
+In addition to the diverse set of ART models, **artlib** offers implementations of
+visualization methods for various cluster geometries, along with pre-processing
+techniques such as Visual Assessment of Tendency (VAT) [@bezdek2002vat], data
+normalization, and complement coding.
+
+[comment]: <> (, including Fuzzy ART)
+
+[comment]: <> ([@carpenter1991fuzzy], Hypersphere ART [@anagnostopoulos2000hypersphere], Ellipsoid ART)
+
+[comment]: <> ([@anagnostopoulos2001a; @anagnostopoulos2001b], Gaussian ART)
+
+[comment]: <> ([@williamson1996gaussian], Bayesian ART [@vigdor2007bayesian], Quadratic Neuron)
+
+[comment]: <> (ART [@su2001application; @su2005new], ART1 [@carpenter1987massively], ART2)
+
+[comment]: <> ([@carpenter1987art; @carpenter1991art], ARTMAP [@carpenter1991artmap], Simplified)
+
+[comment]: <> (ARTMAP [@gotarredona1998adaptive], SMART [@bartfai1994hierarchical], TopoART)
+
+[comment]: <> ([@tscherepanow2010topoart], Dual Vigilance ART [@da2019dual], CVIART [@da2022icvi],)
+
+[comment]: <> (BARTMAP [@xu2011bartmap; @xu2012biclustering], Fusion ART [@tan2007intelligence],)
+
+[comment]: <> (FALCON [@tan2004falcon], and TD-FALCON [@tan2008integrating]. )
+
+# Adaptive Resonance Theory (ART)
+
+ART is a class of neural networks known for solving the stability-plasticity dilemma,
+making it particularly effective for classification, clustering, and incremental
+learning tasks [@grossberg1976a; @grossberg1976a; @Grossberg1980HowDA;
+@grossberg2013adaptive; @da2019survey]. ART models are designed to dynamically learn
+and adapt to new patterns without catastrophic forgetting, making them ideal for
+real-time systems requiring continuous learning.
+
+Over the years, dozens of ART variations have been published [@da2019survey],
+extending the applicability of ART to nearly all learning regimes, including
+reinforcement learning [@tan2004falcon; @tan2008integrating], hierarchical
+clustering [@bartfai1994hierarchical], topological clustering
+[@tscherepanow2010topoart], and biclustering [@xu2011bartmap; @xu2012biclustering].
+These numerous models provide an ART-based solution for most machine learning use cases.
+However, the rapid pace of bespoke model development, coupled with the challenges
+students face in learning ART's foundational principles, has contributed to a
+scarcity of open-source, user-friendly implementations for most ART variants.
+
+The ability of ART to preserve previously learned patterns while learning new data in
+real-time has made it a powerful tool in domains such as robotics, medical diagnosis,
+and adaptive control systems. **artlib** aims to extend the application of these models
+in modern machine learning pipelines, offering a unique and approachable toolkit for
+leveraging ART's strengths.
+
+
+## Elementary Models
+An elementary ART model is a model capable of unsupervised learning and which relies
+on no subordinate ART model for its behavior. The following elementary ART
+models are currently implemented:
+
+1. ART1 [@carpenter1987massively]: ART1 was the first ART clustering algorithm to be
+   developed. It clusters binary vectors using a similarity metric based on the Hamming
+   distance.
+
+2. ART2 [@carpenter1987art; @carpenter1991art]: ART2 was the first attempt to extend
+   ART1 to the domain of continuous-valued data. ART2-A was developed shortly after and
+   improved the algorithmic complexity while retaining the properties of the original
+   ART2 implementation. ART2 more closely resembles a multi-layer perceptron network as
+   it uses an adaptive weight vector in the activation layer and a Heaviside function
+   for the resonance layer. ART2 is widely considered to not work and is not
+   recommended for use. It is included here for historical purposes.
+
+3. Fuzzy ART [@carpenter1991fuzzy]: is arguably the most widely used
+   ART variant at this time. Fuzzy ART is a hyper-box based clustering method,
+   capable of clustering continuous-valued data. Data is pre-processed into zero-volume
+   hyper-boxes through the process of complement coding before being used to
+   initialize or expand the volume of a cluster hyper-box. In the fast-learning regime,
+   Fuzzy ART suffers no catastrophic forgetting. It is exceptionally fast and
+   explainable.
+
+4. Hyperpshere ART [@anagnostopoulos2000hypersphere]: Hypersphere ART was designed
+   to succeed Fuzzy ART with a more efficient internal knowledge representation.
+   Categories are hyper-pspheres and require less internal memory. However,
+   computational complexity is increased relative to Fuzzy ART.
+
+5. Ellipsoid ART[@anagnostopoulos2001a; @anagnostopoulos2001b]: Ellipsoid ART is a
+   generalization of Hyperpshere ART which finds ellipsoidal clusters with arbitrary
+   rotation. Ellipsoid ART is highly order-dependent.
+
+6. Guassian ART [@williamson1996gaussian]: Gaussian ART clusters data in Gaussian
+   distributions (Hyper-ellipsoids) and is similar to Bayesian ART but differs in
+   that the hyper-ellipsoid always have their principal axes square to the
+   coordinate frame. It is also faster than Bayesian ART.
+
+7. Bayesian ART [@vigdor2007bayesian]: Bayesian ART clusters data in Bayesian
+   Distributions (Hyper-ellipsoids) and is similar to Gaussian ART but differs in
+   that it allows arbitrary rotation of the hyper-ellipsoid.
+
+8. Quadratic Neuron ART [@su2001application; @su2005new]: QN-ART uses a neural
+   activation and resonance method to create clusters in a hyper-ellipsoid structure. It
+   is superficially similar to ART2-A in that it uses a weight vector but it is more
+   sophisticated in that neurons also learn a bias and quadratic activation term.
+
+## Compound Models
+A compound ART model is one which extends the functionality of one or more
+underlying ART models. Functional extension may include topology learning,
+supervised learning, reinforcement learning, and more. The following compound ART
+models are currently implemented:
+
+1. ARTMAP [@carpenter1991artmap]: ARTMAP uses two ART modules to separately cluster
+   two parallel data streams (A and B). An inter-ART module regulates the clustering
+   such that clusters in the `module_A` maintain a many-to-one mapping with the
+   clusters in the `module_B` by using a match-tracking function. When the data stream
+   are independent and dependent variables for the A and B side respectively, ARTMAP
+   learns a functional mapping that describes their relationship. ARTMAP can
+   therefore be used for both classification and regression tasks.
+
+2. Simple ARTMAP [@gotarredona1998adaptive]: Simple ARTMAP (or Simplified ARTMAP)
+   was developed to streamline the ARTMAP algorithm for classification tasks. As most
+   classification problems provide discrete labels, it is possible to replace the
+   B-side of the ARTMAP algorithm with the class labels directly. Simple ARTMAP does
+   this and creates a mapping from B-side class labels to A-side cluster labels. The
+   many-to-one mapping property is preserved, but the learning process is
+   significantly faster and less computationally intensive. Simple ARTMAP is the
+   recommended model for classification tasks.
+
+3. Fusion ART [@tan2007intelligence]: Fusion ART allows for the fusion of
+   multi-channel data by leveraging a discrete elementary ART module for each channel.
+   Activation for each category is calculated as a weighted sum of all channel
+   activations and resonance only occurs if all channels are simultaneously resonant.
+   This allows Fusion ART to learn mappings across all channels. Fusion ART works
+   exceptionally well for regression problems and is the recommended model for this
+   task.
+
+4. FALCON [@tan2004falcon]: The Reactive Fusion Architecture for Learning, Cognition,
+   and Navigation (FALCON) model is a special case of Fusion ART designed for
+   reinforcement learning. A Fusion ART model is used for learning the mapping
+   between state, action, and reward channels. Special functions are implemented for
+   selecting the optimal action and for predicting reward values.
+
+5. TD-FALCON [@tan2008integrating]: Temporal Difference (TD) FALCON is an extension of
+   Reactive FALCON which utilizes the SARSA (State-Action-Reward-State-Action) method
+   for temporal difference reinforcement learning. TD-FALCON is the recommended
+   model for reinforcement learning tasks.
+
+6. Dual Vigilance ART [@da2019dual]: Dual Vigilance ART extends an elementary ART
+   module with a second, less restrictive vigilance parameter. Further, Dual
+   Vigilance ART distinguishes between clusters and category lables by mapping
+   clusters to category labels in a many-to-one fashion. Clusters are formed
+   using the typical process for the underlying art module unless no resonant
+   cluster is found. When this occurs, the less-restrictive vigilance parameter is
+   used to determine if a near-resonant cluster can be found. If one can be found,
+   a new cluster is formed, and the near-resonant cluster's category label is mapped to
+   the new cluster. If neither a resonant nor near-resonant category can be found, a new
+   cluster and new category label are both created and mapped to eachother. In this
+   way, Dual Vigilance ART is capable of finding arbitrarily shaped structures as
+   topological composites of the underlying ART geometry (i.e Hyper-ellipsoids or
+   Hyper-boxes).
+
+7. SMART [@bartfai1994hierarchical]: Self-consistent Modular ART (SMART) is a special
+   case of Deep ARTMAP and an extension of ARTMAP. SMART permits n-many modules (in
+   contrast to ARTMAP's 2-modules) which have vigilance parameters monotonically
+   increasing with depth. By passing the same sample vector to all modules, SMART
+   creates a self-consistent hierarchy of clusters through a divisive clustering
+   approach. The number of modules and granularity at each module are both
+   parameterizable.
+
+8. Deep ARTMAP: Deep ARTMAP is a novel contribution of this library. It generalizes
+   SMART by permitting each module `module_i` to accept some function $f^i(x)$. This
+   generalization allows the user to find hierarchical relationships between an
+   abritrary number of functional transformations of some input data. When only two
+   modules are used and $f^1(x) = target(x)$ and $f^2(x) = x$ Deep ARTMAP reduces to
+   standard ARTMAP. Similarly, when the functional transformation at each layer is
+   the identity function, Deep ARTMAP reduces to SMART.
+
+9. Topo ART [@tscherepanow2010topoart]: Topo ART is a topological clustering
+   approach which uses an elementary ART module to learn a distributed clustering graph
+   where samples can belong to multiple distinct clusters. The co-resonant clusters are
+   tracked using an adjacency matrix which describes the cluster relationships of
+   the entire model.
+
+10. CVI ART [@da2022icvi]: CVI ART maps the clusters of an elementary ART module to
+    category label identified by the optimal Cluster Validity Index (CV). This
+    mapping occurs similarly to simplified ARTMAP. An iterative implementation (iCVI
+    ART) is also provided, however it is currently only compatible with Fuzzy ART.
+
+11. BARTMAP [@xu2011bartmap; @xu2012biclustering]: BARTMAP is a Biclustering
+    algorithm based loosely on ARTMAP. The algorithm accepts two instantiated
+    elementary ART modules `module_A` and `module_B` which cluster the rows (samples)
+    and columns (features) respectively. The features are clustered independently,
+    but the samples are clustered by considering samples already within a row
+    cluster as well as the candidate sample and enforcing a minimum Pearson correlation
+    within the subset of features belonging to at least one of the feature clusters.
+
+
+
+## General Usage
+To install AdaptiveResonanceLib, simply use pip:
+
+```bash
+pip install artlib
+```
+
+### Clustering Data with the Fuzzy ART model
+
+```python
+from artlib import FuzzyART
+import numpy as np
+
+# Your dataset
+train_X = np.array([...]) # shape (n_samples, n_features)
+test_X = np.array([...])
+
+# Initialize the Fuzzy ART model
+model = FuzzyART(rho=0.7, alpha = 0.0, beta=1.0)
+
+# Prepare Data
+train_X_prep = model.prepare_data(train_X)
+test_X_prep = model.prepare_data(test_X)
+
+# Fit the model
+model.fit(train_X_prep)
+
+# Predict data labels
+predictions = model.predict(test_X_prep)
+```
+
+### Fitting a Classification Model with SimpleARTMAP
+
+```python
+from artlib import GaussianART, SimpleARTMAP
+import numpy as np
+
+# Your dataset
+train_X = np.array([...]) # shape (n_samples, n_features)
+train_y = np.array([...]) # shape (n_samples, ), must be integers
+test_X = np.array([...])
+
+# Initialize the Gaussian ART model
+sigma_init = np.array([0.5]*train_X.shape[1]) # variance estimate for each feature
+module_a = GaussianART(rho=0.0, sigma_init=sigma_init)
+
+# Initialize the SimpleARTMAP model
+model = SimpleARTMAP(module_a=module_a)
+
+# Prepare Data
+train_X_prep = model.prepare_data(train_X)
+test_X_prep = model.prepare_data(test_X)
+
+# Fit the model
+model.fit(train_X_prep, train_y)
+
+# Predict data labels
+predictions = model.predict(test_X_prep)
+```
+
+### Fitting a Regression Model with FusionART
+
+```python
+from artlib import FuzzyART, HypersphereART, FusionART
+import numpy as np
+
+# Your dataset
+train_X = np.array([...]) # shape (n_samples, n_features_X)
+train_y = np.array([...]) # shape (n_samples, n_features_y)
+test_X = np.array([...])
+
+# Initialize the Fuzzy ART model
+module_x = FuzzyART(rho=0.0, alpha = 0.0, beta=1.0)
+
+# Initialize the Hypersphere ART model
+r_hat = 0.5*np.sqrt(train_X.shape[1]) # no restriction on hyperpshere size
+module_y = HypersphereART(rho=0.0, alpha = 0.0, beta=1.0, r_hat=r_hat)
+
+# Initialize the FusionARTMAP model
+gamma_values = [0.5, 0.5] # eqaul weight to both channels
+channel_dims = [
+  2*train_X.shape[1], # fuzzy ART complement codes data so channel dim is 2*n_features
+  train_y.shape[1]
+]
+model = FusionART(
+  modules=[module_x, module_y],
+  gamma_values=gamma_values,
+  channel_dims=channel_dims
+)
+
+# Prepare Data
+train_Xy = model.join_channel_data(channel_data=[train_X, train_y])
+train_Xy_prep = model.prepare_data(train_Xy)
+test_Xy = model.join_channel_data(channel_data=[train_X], skip_channels=[1])
+test_Xy_prep = model.prepare_data(test_Xy)
+
+# Fit the model
+model.fit(train_X_prep, train_y)
+
+# Predict y-channel values
+pred_y = model.predict_regression(test_Xy_prep, target_channels=[1])
+```
+
+
+# Statement of Need
+
+The Adaptive Resonance Library (**artlib**) is essential for researchers, developers,
+and educators interested in adaptive neural networks, specifically ART algorithms.
+While deep learning dominates machine learning, ART models offer unique advantages
+in incremental and real-time learning environments due to their ability to learn new
+data without forgetting previously learned information.
+
+Currently, no comprehensive Python library implements a variety of ART models in an
+open-source, modular, and extensible manner. **artlib** fills this gap by offering a
+range of ART implementations that integrate seamlessly with machine learning workflows,
+including scikit-learn's `Pipeline` and `GridSearchCV` [@scikit-learn]. The library is
+designed for ease of use and high performance, leveraging Python's scientific stack
+(NumPy [@harris2020array], SciPy [@2020SciPy-NMeth], and scikit-learn [@scikit-learn])
+for fast numerical computation.
+
+The modular design of **artlib** enables users to create novel compound ART models,
+such as Dual Vigilance Fusion ART [@da2019dual; @tan2007intelligence] or
+Quadratic Neuron SMART [@su2001application; @su2005new; @bartfai1994hierarchical].
+This flexibility offers powerful experimental and time-saving benefits, allowing
+researchers and practitioners to evaluate models on diverse datasets efficiently.
+
+Additionally, the library serves as a valuable educational tool, providing
+well-documented code and familiar APIs to support hands-on experimentation with ART
+models. It is ideal for academic courses or personal projects in artificial
+intelligence and machine learning, making **artlib** a versatile resource.
+
+**artlib** is actively maintained and designed for future extension, allowing users
+to create new ART models, adjust parameters for specific applications, and explore ART's
+potential for novel research problems. Its integration with popular Python libraries
+ensures its adaptability to current machine learning challenges.
+
+# Comparison to Existing Implementations
+
+While there are several open-source repositories that provide
+python implementations of specific ART models [@birkjohann2023artpython;
+@aiopenlab2023art; @dilekman2022artificial; @artpy2022; @dixit2020adaptive;
+@inyoot2021art; @valixandra2021adaptive; @wan2022art2; @ray2023artpy], they lack
+modularity and are limited in scope, often implementing just one or two models. For
+instance, MATLAB-based ART toolboxes [@mathworks_art1s; @mathworks_fuzzyart_fuzzyartmap;
+@mathworks_topoart; @mathworks_art_fuzzyart_artmap] provide implementations of
+Fuzzy ART, TopoART, ART1, and ARTMAP models, but they lack the flexibility and
+modularity required for broader experimentation. The most significant existing ART
+implementation exists in julia and provides just five models
+[@Petrenko_AdaptiveResonance_jl_A_Julia_2022] but, like the previously listed
+MATLAB-based toolboxes, it is not easily accessible to Python-based work flows and
+lacks a modular design.
+
+These existing implementations of ART models may provide standalone versions of
+individual models, but they are often not designed to integrate seamlessly with modern
+Python libraries such as scikit-learn, NumPy, and SciPy. As a result, researchers and
+developers working in Python-based environments face challenges when trying to
+incorporate ART models into their machine learning pipelines.
+
+In contrast, **artlib** offers a comprehensive and modular collection of ART models,
+including both elementary and compound ART architectures. It is designed for
+interoperability with popular Python tools, enabling users to easily integrate ART
+models into machine learning workflows, optimize models using scikit-learn's
+`GridSearchCV`, and preprocess data using standard libraries. Further, **artlib**
+provides users the flexibility to construct their own compound ART modules (those
+art modules deriving properties from other, elementary modules) which
+may or may not exist in published literature. **artlib** also provides a template
+in the source code to encourage users to develop and experiment with their own custom
+ART algorithms. This flexibility and integration make **artlib** a powerful resource
+for both research and practical applications.
+
+
+# Acknowledgements
+
+This research was supported by the National Science Foundation (NSF) under Award
+Number 2420248. The project titled EAGER: Solving Representation Learning and
+Catastrophic Forgetting with Adaptive Resonance Theory provided essential funding for
+the completion of this work.
+
+We would also like to thank Gail Carpenter and Stephen Grossberg for their
+feedback regarding this project and their immense contribution to machine learning by
+pioneering Adaptive Resonance Theory.
+
+# References
diff --git a/pyproject.toml b/pyproject.toml
index 9815bd0..c42a278 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,15 @@ matplotlib = ">=3.3.3"
 
 [tool.poetry.dev-dependencies]
 pytest = "^6.2.2"
+pre-commit = "^4.0.0"
+
+[tool.poetry.group.docs.dependencies]
+sphinx = "^6.2.1"
+sphinx-rtd-theme = "^1.0.0"  # If you're using the Read the Docs theme
+sphinx-autoapi = ">=3.0.0"
+myst-parser = "^1.0"
+cffconvert = "^2.0.0"
+sphinxcontrib-bibtex = "^2.0.0"
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]
diff --git a/readthedocs.yaml b/readthedocs.yaml
new file mode 100644
index 0000000..91db34b
--- /dev/null
+++ b/readthedocs.yaml
@@ -0,0 +1,27 @@
+version: 2
+
+build:
+  os: "ubuntu-22.04"
+  tools:
+    python: "3.9"
+  jobs:
+    post_create_environment:
+      # Install poetry
+      # https://python-poetry.org/docs/#installing-manually
+      - pip install poetry
+    post_install:
+      # Install dependencies with 'docs' dependency group
+      # https://python-poetry.org/docs/managing-dependencies/#dependency-groups
+      # VIRTUAL_ENV needs to be set manually for now.
+      # See https://github.com/readthedocs/readthedocs.org/pull/11152/
+      - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
+      # Generate references.bib using the Python script
+      - python scripts/generate_references.py
+
+
+
+
+
+
+sphinx:
+  configuration: docs/source/conf.py
diff --git a/references.bib b/references.bib
new file mode 100644
index 0000000..50abb3d
--- /dev/null
+++ b/references.bib
@@ -0,0 +1,462 @@
+@inproceedings{bezdek2002vat,
+  title={VAT: A tool for visual assessment of (cluster) tendency},
+  author={Bezdek, James C and Hathaway, Richard J},
+  booktitle={Proceedings of the 2002 International Joint Conference on Neural Networks. IJCNN'02 (Cat. No. 02CH37290)},
+  volume={3},
+  pages={2225--2230},
+  year={2002},
+  organization={IEEE}
+}
+@article{da2022icvi,
+  title={iCVI-ARTMAP: using incremental cluster validity indices and adaptive resonance theory reset mechanism to accelerate validation and achieve multiprototype unsupervised representations},
+  author={da Silva, Leonardo Enzo Brito and Rayapati, Nagasharath and Wunsch, Donald C},
+  journal={IEEE Transactions on Neural Networks and Learning Systems},
+  volume={34},
+  number={12},
+  pages={9757--9770},
+  year={2022},
+  publisher={IEEE}
+}
+@article{carpenter1987massively,
+  title={A massively parallel architecture for a self-organizing neural pattern recognition machine},
+  author={Carpenter, Gail A and Grossberg, Stephen},
+  journal={Computer vision, graphics, and image processing},
+  volume={37},
+  number={1},
+  pages={54--115},
+  year={1987},
+  publisher={Elsevier}
+}
+@article{carpenter1987art,
+  title={ART 2: Self-organization of stable category recognition codes for analog input patterns},
+  author={Carpenter, Gail A and Grossberg, Stephen},
+  journal={Applied optics},
+  volume={26},
+  number={23},
+  pages={4919--4930},
+  year={1987},
+  publisher={Optica Publishing Group}
+}
+@article{carpenter1991art,
+  title={ART 2-A: An adaptive resonance algorithm for rapid category learning and recognition},
+  author={Carpenter, Gail A and Grossberg, Stephen and Rosen, David B},
+  journal={Neural networks},
+  volume={4},
+  number={4},
+  pages={493--504},
+  year={1991},
+  publisher={Elsevier}
+}
+@article{vigdor2007bayesian,
+  title={The bayesian artmap},
+  author={Vigdor, Boaz and Lerner, Boaz},
+  journal={IEEE Transactions on Neural Networks},
+  volume={18},
+  number={6},
+  pages={1628--1644},
+  year={2007},
+  publisher={IEEE}
+}
+@inproceedings{anagnostopoulos2001a,
+  title={Ellipsoid ART and ARTMAP for incremental clustering and classification},
+  author={Anagnostopoulos, Georgios C and Georgiopoulos, Michael},
+  booktitle={IJCNN'01. International Joint Conference on Neural Networks. Proceedings (Cat. No. 01CH37222)},
+  volume={2},
+  pages={1221--1226},
+  year={2001},
+  organization={IEEE}
+}
+@inproceedings{anagnostopoulos2001b,
+  title={Ellipsoid ART and ARTMAP for incremental unsupervised and supervised learning},
+  author={Anagnostopoulos, Georgios C and Georgiopoulos, Michael},
+  booktitle={Applications and Science of Computational Intelligence IV},
+  volume={4390},
+  pages={293--304},
+  year={2001},
+  organization={SPIE}
+}
+@article{carpenter1991fuzzy,
+  title={Fuzzy ART: Fast stable learning and categorization of analog patterns by an adaptive resonance system},
+  author={Carpenter, Gail A and Grossberg, Stephen and Rosen, David B},
+  journal={Neural networks},
+  volume={4},
+  number={6},
+  pages={759--771},
+  year={1991},
+  publisher={Elsevier}
+}
+@article{williamson1996gaussian,
+  title={Gaussian ARTMAP: A neural network for fast incremental learning of noisy multidimensional maps},
+  author={Williamson, James R},
+  journal={Neural networks},
+  volume={9},
+  number={5},
+  pages={881--897},
+  year={1996},
+  publisher={Elsevier}
+}
+@inproceedings{anagnostopoulos2000hypersphere,
+  title={Hypersphere ART and ARTMAP for unsupervised and supervised, incremental learning},
+  author={Anagnostopoulos, Georgios C and Georgiopulos, M},
+  booktitle={Proceedings of the IEEE-INNS-ENNS International Joint Conference on Neural Networks. IJCNN 2000. Neural Computing: New Challenges and Perspectives for the New Millennium},
+  volume={6},
+  pages={59--64},
+  year={2000},
+  organization={IEEE}
+}
+@article{su2001application,
+  title={Application of neural networks using quadratic junctions in cluster analysis},
+  author={Su, Mu-Chun and Liu, Ta-Kang},
+  journal={Neurocomputing},
+  volume={37},
+  number={1-4},
+  pages={165--175},
+  year={2001},
+  publisher={Elsevier}
+}
+@article{su2005new,
+  title={A new approach to clustering data with arbitrary shapes},
+  author={Su, Mu-chun and Liu, Yi-chun},
+  journal={pattern recognition},
+  volume={38},
+  number={11},
+  pages={1887--1901},
+  year={2005},
+  publisher={Elsevier}
+}
+@inproceedings{tan2007intelligence,
+  title={Intelligence through interaction: Towards a unified theory for learning},
+  author={Tan, Ah-Hwee and Carpenter, Gail A and Grossberg, Stephen},
+  booktitle={International Symposium on Neural Networks},
+  pages={1094--1103},
+  year={2007},
+  organization={Springer}
+}
+@inproceedings{bartfai1994hierarchical,
+  title={Hierarchical clustering with ART neural networks},
+  author={Bartfai, Guszti},
+  booktitle={Proceedings of 1994 IEEE International Conference on Neural Networks (ICNN'94)},
+  volume={2},
+  pages={940--944},
+  year={1994},
+  organization={IEEE}
+}
+@inproceedings{tan2004falcon,
+  title={FALCON: A fusion architecture for learning, cognition, and navigation},
+  author={Tan, Ah-Hwee},
+  booktitle={2004 IEEE International Joint Conference on Neural Networks (IEEE Cat. No. 04CH37541)},
+  volume={4},
+  pages={3297--3302},
+  year={2004},
+  organization={IEEE}
+}
+@article{tan2008integrating,
+  title={Integrating temporal difference methods and self-organizing neural networks for reinforcement learning with delayed evaluative feedback},
+  author={Tan, Ah-Hwee and Lu, Ning and Xiao, Dan},
+  journal={IEEE transactions on neural networks},
+  volume={19},
+  number={2},
+  pages={230--244},
+  year={2008},
+  publisher={IEEE}
+}
+@inproceedings{carpenter1991artmap,
+  author={Carpenter, G.A. and Grossberg, S. and Reynolds, J.H.},
+  booktitle={[1991 Proceedings] IEEE Conference on Neural Networks for Ocean Engineering},
+  title={ARTMAP: supervised real-time learning and classification of nonstationary data by a self-organizing neural network},
+  year={1991},
+  volume={},
+  number={},
+  pages={341-342},
+  keywords={Subspace constraints;Databases;Neural networks;Supervised learning;Resonance;Pattern recognition;System testing;Benchmark testing;Machine learning;Machine learning algorithms},
+  doi={10.1109/ICNN.1991.163370}
+}
+@misc{gotarredona1998adaptive,
+  title={Adaptive Resonance Theory Microchips Circuit Design Techniques},
+  author={Gotarredona, Teresa Serrano and Barranco, B Linares and Andreou, AG},
+  year={1998},
+  publisher={Norwell, MA: Kluwer}
+}
+@article{da2019dual,
+  title={Dual vigilance fuzzy adaptive resonance theory},
+  author={da Silva, Leonardo Enzo Brito and Elnabarawy, Islam and Wunsch II, Donald C},
+  journal={Neural Networks},
+  volume={109},
+  pages={1--5},
+  year={2019},
+  publisher={Elsevier}
+}
+@inproceedings{tscherepanow2010topoart,
+  title={TopoART: A topology learning hierarchical ART network},
+  author={Tscherepanow, Marko},
+  booktitle={International Conference on Artificial Neural Networks},
+  pages={157--167},
+  year={2010},
+  organization={Springer}
+}
+@article{scikit-learn,
+  title={Scikit-learn: Machine Learning in {P}ython},
+  author={Pedregosa, F. and Varoquaux, G. and Gramfort, A. and Michel, V.
+          and Thirion, B. and Grisel, O. and Blondel, M. and Prettenhofer, P.
+          and Weiss, R. and Dubourg, V. and Vanderplas, J. and Passos, A. and
+          Cournapeau, D. and Brucher, M. and Perrot, M. and Duchesnay, E.},
+  journal={Journal of Machine Learning Research},
+  volume={12},
+  pages={2825--2830},
+  year={2011}
+}
+@ARTICLE{2020SciPy-NMeth,
+  author  = {Virtanen, Pauli and Gommers, Ralf and Oliphant, Travis E. and
+            Haberland, Matt and Reddy, Tyler and Cournapeau, David and
+            Burovski, Evgeni and Peterson, Pearu and Weckesser, Warren and
+            Bright, Jonathan and {van der Walt}, St{\'e}fan J. and
+            Brett, Matthew and Wilson, Joshua and Millman, K. Jarrod and
+            Mayorov, Nikolay and Nelson, Andrew R. J. and Jones, Eric and
+            Kern, Robert and Larson, Eric and Carey, C J and
+            Polat, {\.I}lhan and Feng, Yu and Moore, Eric W. and
+            {VanderPlas}, Jake and Laxalde, Denis and Perktold, Josef and
+            Cimrman, Robert and Henriksen, Ian and Quintero, E. A. and
+            Harris, Charles R. and Archibald, Anne M. and
+            Ribeiro, Ant{\^o}nio H. and Pedregosa, Fabian and
+            {van Mulbregt}, Paul and {SciPy 1.0 Contributors}},
+  title   = {{{SciPy} 1.0: Fundamental Algorithms for Scientific
+            Computing in Python}},
+  journal = {Nature Methods},
+  year    = {2020},
+  volume  = {17},
+  pages   = {261--272},
+  adsurl  = {https://rdcu.be/b08Wh},
+  doi     = {10.1038/s41592-019-0686-2},
+}
+@Article{         harris2020array,
+ title         = {Array programming with {NumPy}},
+ author        = {Charles R. Harris and K. Jarrod Millman and St{\'{e}}fan J.
+                 van der Walt and Ralf Gommers and Pauli Virtanen and David
+                 Cournapeau and Eric Wieser and Julian Taylor and Sebastian
+                 Berg and Nathaniel J. Smith and Robert Kern and Matti Picus
+                 and Stephan Hoyer and Marten H. van Kerkwijk and Matthew
+                 Brett and Allan Haldane and Jaime Fern{\'{a}}ndez del
+                 R{\'{i}}o and Mark Wiebe and Pearu Peterson and Pierre
+                 G{\'{e}}rard-Marchant and Kevin Sheppard and Tyler Reddy and
+                 Warren Weckesser and Hameer Abbasi and Christoph Gohlke and
+                 Travis E. Oliphant},
+ year          = {2020},
+ month         = sep,
+ journal       = {Nature},
+ volume        = {585},
+ number        = {7825},
+ pages         = {357--362},
+ doi           = {10.1038/s41586-020-2649-2},
+ publisher     = {Springer Science and Business Media {LLC}},
+ url           = {https://doi.org/10.1038/s41586-020-2649-2}
+}
+@article{da2019survey,
+  title={A survey of adaptive resonance theory neural network models for engineering applications},
+  author={da Silva, Leonardo Enzo Brito and Elnabarawy, Islam and Wunsch II, Donald C},
+  journal={Neural Networks},
+  volume={120},
+  pages={167--203},
+  year={2019},
+  publisher={Elsevier}
+}
+
+@article{grossberg1976a,
+  title={Adaptive pattern classification and universal recoding: I. Parallel development and coding of neural feature detectors},
+  author={Grossberg, Stephen},
+  journal={Biological cybernetics},
+  volume={23},
+  number={3},
+  pages={121--134},
+  year={1976},
+  publisher={Springer}
+}
+@article{grossberg1976ab,
+  title={Adaptive pattern classification and universal recoding: II. Feedback, expectation, olfaction, illusions},
+  author={Grossberg, Stephen},
+  journal={Biological cybernetics},
+  volume={23},
+  number={4},
+  pages={187--202},
+  year={1976},
+  publisher={Springer}
+}
+@article{Grossberg1980HowDA,
+  title={How does a brain build a cognitive code?},
+  author={Stephen Grossberg},
+  journal={Psychological review},
+  year={1980},
+  volume={87 1},
+  pages={
+          1-51
+        }
+}
+@article{grossberg2013adaptive,
+  title={Adaptive Resonance Theory: How a brain learns to consciously attend, learn, and recognize a changing world},
+  author={Grossberg, Stephen},
+  journal={Neural networks},
+  volume={37},
+  pages={1--47},
+  year={2013},
+  publisher={Elsevier}
+}
+
+@misc{birkjohann2023artpython,
+  author       = {Christian Birkjohann},
+  title        = {art-python: Adaptive Resonance Theory in Python},
+  year         = {2023},
+  url          = {https://github.com/cbirkj/art-python},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/cbirkj/art-python}},
+  version      = {v0.1.0},
+  urldate      = {2024-10-19}
+}
+@misc{aiopenlab2023art,
+  author       = {{AI OpenLab}},
+  title        = {ART: Adaptive Resonance Theory Implementation},
+  year         = {2023},
+  url          = {https://github.com/AIOpenLab/art},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/AIOpenLab/art}},
+  version      = {v1.0.0},
+  urldate      = {2024-10-19}
+}
+@misc{dilekman2022artificial,
+  author       = {Kerem Dilekman},
+  title        = {Artificial Neural Network - Adaptive Resonance Theory},
+  year         = {2022},
+  url          = {https://github.com/KeremDlkmn/artificial-neural-network-adaptive-resonance-theory},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/KeremDlkmn/artificial-neural-network-adaptive-resonance-theory}},
+  version      = {v0.1.0},
+  urldate      = {2024-10-19}
+}
+@misc{artpy2022,
+  author       = {Sergey Stepanov},
+  title        = {ARTpy: A Python Implementation of Adaptive Resonance Theory},
+  year         = {2022},
+  url          = {https://github.com/DeadAt0m/ARTpy},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/DeadAt0m/ARTpy}},
+  version      = {v1.0.0},
+  urldate      = {2024-10-19}
+}
+@misc{dixit2020adaptive,
+  author       = {Meeti Dixit},
+  title        = {Adaptive Resonance Theory},
+  year         = {2020},
+  url          = {https://github.com/MeetiDixit/AdaptiveResonanceTheory},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/MeetiDixit/AdaptiveResonanceTheory}},
+  version      = {v1.0.0},
+  urldate      = {2024-10-19}
+}
+@misc{inyoot2021art,
+  author       = {Inyoot},
+  title        = {ART: Adaptive Resonance Theory Implementation in Python},
+  year         = {2021},
+  url          = {https://github.com/inyoot/art},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/inyoot/art}},
+  version      = {v0.1.0},
+  urldate      = {2024-10-19}
+}
+@misc{valixandra2021adaptive,
+  author       = {Filip Valixandra},
+  title        = {Adaptive Resonance Theory},
+  year         = {2021},
+  url          = {https://github.com/flppvalxndra/Adaptive-Resonance-Theory},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/flppvalxndra/Adaptive-Resonance-Theory}},
+  version      = {v1.0.0},
+  urldate      = {2024-10-19}
+}
+@misc{wan2022art2,
+  author       = {YuChang Wan},
+  title        = {ART2: Adaptive Resonance Theory 2 Implementation},
+  year         = {2022},
+  url          = {https://github.com/YuChangWan/ART2},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/YuChangWan/ART2}},
+  version      = {v1.0.0},
+  urldate      = {2024-10-19}
+}
+@misc{ray2023artpy,
+  author       = {Ray11641},
+  title        = {artpy: Adaptive Resonance Theory in Python},
+  year         = {2023},
+  url          = {https://github.com/Ray11641/artpy},
+  note         = {GitHub repository},
+  howpublished = {\url{https://github.com/Ray11641/artpy}},
+  version      = {v0.1.0},
+  urldate      = {2024-10-19}
+}
+@article{Petrenko_AdaptiveResonance_jl_A_Julia_2022,
+author = {Petrenko, Sasha and Wunsch, II, Donald},
+journal = {Journal of Open Source Software},
+month = apr,
+title = {{AdaptiveResonance.jl: A Julia Implementation of Adaptive Resonance Theory (ART) Algorithms}},
+url = {https://doi.org/10.21105/joss.03671},
+volume = {7},
+year = {2022}
+}
+
+@misc{mathworks_art1s,
+  author       = {Mark Harris},
+  title        = {ART1S: Adaptive Resonance Theory 1 (ART1)},
+  year         = {2000},
+  url          = {https://www.mathworks.com/matlabcentral/fileexchange/93-art1s-zip},
+  note         = {MATLAB Central File Exchange},
+  howpublished = {\url{https://www.mathworks.com/matlabcentral/fileexchange/93-art1s-zip}},
+  urldate      = {2024-10-19}
+}
+
+@misc{mathworks_fuzzyart_fuzzyartmap,
+  author       = {Friedhelm Schwenker},
+  title        = {Fuzzy ART and Fuzzy ARTMAP Neural Networks},
+  year         = {2004},
+  url          = {https://www.mathworks.com/matlabcentral/fileexchange/4306-fuzzy-art-and-fuzzy-artmap-neural-networks},
+  note         = {MATLAB Central File Exchange},
+  howpublished = {\url{https://www.mathworks.com/matlabcentral/fileexchange/4306-fuzzy-art-and-fuzzy-artmap-neural-networks}},
+  urldate      = {2024-10-19}
+}
+
+@misc{mathworks_topoart,
+  author       = {Martial Boitet},
+  title        = {TopoART Neural Networks},
+  year         = {2022},
+  url          = {https://www.mathworks.com/matlabcentral/fileexchange/118455-topoart-neural-networks},
+  note         = {MATLAB Central File Exchange},
+  howpublished = {\url{https://www.mathworks.com/matlabcentral/fileexchange/118455-topoart-neural-networks}},
+  urldate      = {2024-10-19}
+}
+
+@misc{mathworks_art_fuzzyart_artmap,
+  author       = {Friedhelm Schwenker},
+  title        = {ART1, Fuzzy ART, ARTMAP, Fuzzy ARTMAP Neural Networks},
+  year         = {2004},
+  url          = {https://www.mathworks.com/matlabcentral/fileexchange/3070-art1-fuzzyart-artmap-fuzzyartmap},
+  note         = {MATLAB Central File Exchange},
+  howpublished = {\url{https://www.mathworks.com/matlabcentral/fileexchange/3070-art1-fuzzyart-artmap-fuzzyartmap}},
+  urldate      = {2024-10-19}
+}
+@article{xu2011bartmap,
+  author       = {Rui Xu and Donald C. Wunsch II},
+  title        = {{BARTMAP: A viable structure for biclustering}},
+  journal      = {Neural Networks},
+  volume       = {24},
+  pages        = {709--716},
+  year         = {2011},
+  doi          = {10.1016/j.neunet.2011.03.020},
+  url          = {https://doi.org/10.1016/j.neunet.2011.03.020}
+}
+
+@patent{xu2012biclustering,
+  author       = {Rui Xu and Donald C. Wunsch II and Sang Yong Kim},
+  title        = {Methods and systems for biclustering algorithm},
+  number       = {US9,043,326B2},
+  year         = {2012},
+  month        = {January 28},
+  note         = {U.S. Patent},
+  url          = {https://patents.google.com/patent/US9043326B2/en},
+  howpublished = {Filed January 28, 2012, issued May 26, 2015}
+}
diff --git a/scripts/comparison_of_methods.py b/scripts/comparison_of_methods.py
new file mode 100644
index 0000000..e71cce6
--- /dev/null
+++ b/scripts/comparison_of_methods.py
@@ -0,0 +1,396 @@
+import time
+import warnings
+from itertools import cycle, islice
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+from sklearn import datasets
+from sklearn.preprocessing import StandardScaler
+
+import artlib
+
+# ============
+# Generate datasets. We choose the size big enough to see the scalability
+# of the algorithms, but not too big to avoid too long running times
+# ============
+n_samples = 500
+seed = 30
+noisy_circles = datasets.make_circles(
+    n_samples=n_samples,
+    factor=0.5,
+    noise=0.05,
+    random_state=seed,
+    shuffle=False,
+)
+noisy_moons = datasets.make_moons(
+    n_samples=n_samples, noise=0.05, random_state=seed, shuffle=False
+)
+blobs = datasets.make_blobs(
+    n_samples=n_samples, random_state=seed, shuffle=False
+)
+rng = np.random.RandomState(seed)
+no_structure = rng.rand(n_samples, 2), None
+
+# Anisotropicly distributed data
+random_state = 170
+X, y = datasets.make_blobs(
+    n_samples=n_samples, random_state=random_state, shuffle=False
+)
+transformation = [[0.6, -0.6], [-0.4, 0.8]]
+X_aniso = np.dot(X, transformation)
+aniso = (X_aniso, y)
+
+# blobs with varied variances
+varied = datasets.make_blobs(
+    n_samples=n_samples,
+    cluster_std=[1.0, 2.5, 0.5],
+    random_state=random_state,
+    shuffle=False,
+)
+
+# ============
+# Set up cluster parameters
+# ============
+plt.figure(figsize=(9 * 2 + 3, 13))
+plt.subplots_adjust(
+    left=0.02, right=0.98, bottom=0.001, top=0.95, wspace=0.05, hspace=0.01
+)
+
+plot_num = 1
+
+datasets = [
+    (
+        noisy_circles,
+        {
+            "BayesianART": {
+                "rho": 9e-5,
+                "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.95, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.9,
+            },
+            "EllipsoidART": {
+                "rho": 0.1,
+                "alpha": 1.0,
+                "beta": 1.0,
+                "r_hat": 0.9,
+                "mu": 0.3,
+            },
+            "FuzzyART": {"rho": 0.266, "alpha": 0.15, "beta": 1.0},
+            "GaussianART": {
+                "rho": 0.1,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.433,
+                "alpha": 0.7,
+                "beta": 1.0,
+                "r_hat": 0.8,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.99,
+                "s_init": 0.8,
+                "lr_b": 0.2,
+                "lr_w": 0.1,
+                "lr_s": 0.2,
+            },
+        },
+    ),
+    (
+        noisy_moons,
+        {
+            "BayesianART": {
+                "rho": 9e-5,
+                "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.85, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.78,
+            },
+            "EllipsoidART": {
+                "rho": 0.166,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.9,
+                "mu": 0.4,
+            },
+            "FuzzyART": {"rho": 0.33, "alpha": 0.0, "beta": 1.0},
+            "GaussianART": {
+                "rho": 0.133,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.2,
+                "alpha": 0.7,
+                "beta": 1.0,
+                "r_hat": 0.7,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.9,
+                "s_init": 0.9,
+                "lr_b": 0.1,
+                "lr_w": 0.1,
+                "lr_s": 0.1,
+            },
+        },
+    ),
+    (
+        varied,
+        {
+            "BayesianART": {
+                "rho": 3e-4,
+                "cov_init": np.array([[1e-3, 0.0], [0.0, 1e-3]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.95, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.88,
+            },
+            "EllipsoidART": {
+                "rho": 0.05,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.7,
+                "mu": 1.0,
+            },
+            "FuzzyART": {"rho": 0.4, "alpha": 0.1, "beta": 1.0},
+            "GaussianART": {
+                "rho": 0.08,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.2,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.3,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.933,
+                "s_init": 0.9,
+                "lr_b": 0.1,
+                "lr_w": 0.1,
+                "lr_s": 0.1,
+            },
+        },
+    ),
+    (
+        aniso,
+        {
+            "BayesianART": {
+                "rho": 2e-5,
+                "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.95, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.9,
+            },
+            "EllipsoidART": {
+                "rho": 5e-5,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.9,
+                "mu": 0.9,
+            },
+            "FuzzyART": {"rho": 0.5667, "alpha": 0.4, "beta": 1.0},
+            "GaussianART": {
+                "rho": 0.3,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.1,
+                "alpha": 0.05,
+                "beta": 1.0,
+                "r_hat": 0.4667,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.933,
+                "s_init": 0.8,
+                "lr_b": 0.4,
+                "lr_w": 0.1,
+                "lr_s": 0.2,
+            },
+        },
+    ),
+    (
+        blobs,
+        {
+            "BayesianART": {
+                "rho": 6e-5,
+                "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.91, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.82,
+            },
+            "EllipsoidART": {
+                "rho": 0.01,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.6,
+                "mu": 1.0,
+            },
+            "FuzzyART": {"rho": 0.433, "alpha": 0.2, "beta": 1.0},
+            "GaussianART": {
+                "rho": 5e-4,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.1,
+                "alpha": 0.05,
+                "beta": 1.0,
+                "r_hat": 0.433,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.95,
+                "s_init": 0.8,
+                "lr_b": 0.05,
+                "lr_w": 0.1,
+                "lr_s": 0.05,
+            },
+        },
+    ),
+    (
+        no_structure,
+        {
+            "BayesianART": {
+                "rho": 2e-5,
+                "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+            },
+            "DualVigilanceART": {
+                "FuzzyART": {"rho": 0.85, "alpha": 0.8, "beta": 1.0},
+                "rho_lower_bound": 0.78,
+            },
+            "EllipsoidART": {
+                "rho": 0.2,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.6,
+                "mu": 0.8,
+            },
+            "FuzzyART": {"rho": 0.7, "alpha": 0.0, "beta": 1.0},
+            "GaussianART": {
+                "rho": 0.15,
+                "sigma_init": np.array([0.5, 0.5]),
+            },
+            "HypersphereART": {
+                "rho": 0.7,
+                "alpha": 0.0,
+                "beta": 1.0,
+                "r_hat": 0.8,
+            },
+            "QuadraticNeuronART": {
+                "rho": 0.9,
+                "s_init": 0.9,
+                "lr_b": 0.1,
+                "lr_w": 0.1,
+                "lr_s": 0.1,
+            },
+        },
+    ),
+]
+
+for i_dataset, (dataset, algo_params) in enumerate(datasets):
+    # update parameters with dataset-specific values
+    params = algo_params
+
+    X, y = dataset
+
+    # normalize dataset for easier parameter selection
+    X = StandardScaler().fit_transform(X)
+
+    # ============
+    # Create cluster objects
+    # ============
+    print(i_dataset)
+    hypersphere = artlib.HypersphereART(**params["HypersphereART"])
+    ellipsoid = artlib.EllipsoidART(**params["EllipsoidART"])
+    gaussian = artlib.GaussianART(**params["GaussianART"])
+    bayesian = artlib.BayesianART(**params["BayesianART"])
+    quadratic_neuron = artlib.QuadraticNeuronART(**params["QuadraticNeuronART"])
+    fuzzy = artlib.FuzzyART(**params["FuzzyART"])
+    fuzzyDV = artlib.DualVigilanceART(
+        artlib.FuzzyART(**params["DualVigilanceART"]["FuzzyART"]),
+        rho_lower_bound=params["DualVigilanceART"]["rho_lower_bound"],
+    )
+
+    clustering_algorithms = (
+        ("Hypersphere\nART", hypersphere),
+        ("Ellipsoid\nART", ellipsoid),
+        ("Gaussian\nART", gaussian),
+        ("Bayesian\nART", bayesian),
+        ("Quadratic Neuron\nART", quadratic_neuron),
+        ("FuzzyART", fuzzy),
+        ("Dual Vigilance\nFuzzyART", fuzzyDV),
+    )
+
+    for name, algorithm in clustering_algorithms:
+        t0 = time.time()
+
+        # catch warnings related to kneighbors_graph
+        with warnings.catch_warnings():
+            warnings.filterwarnings(
+                "ignore",
+                message="the number of connected components of the "
+                + "connectivity matrix is [0-9]{1,2}"
+                + " > 1. Completing it to avoid stopping the tree early.",
+                category=UserWarning,
+            )
+            warnings.filterwarnings(
+                "ignore",
+                message="Graph is not fully connected, spectral embedding"
+                + " may not work as expected.",
+                category=UserWarning,
+            )
+            X_prepared = algorithm.prepare_data(X)
+            algorithm.fit(X_prepared)
+
+        t1 = time.time()
+        if hasattr(algorithm, "labels_"):
+            y_pred = algorithm.labels_.astype(int)
+        else:
+            y_pred = algorithm.predict(X_prepared)
+
+        plt.subplot(len(datasets), len(clustering_algorithms), plot_num)
+        if i_dataset == 0:
+            plt.title(name, size=18)
+
+        colors = np.array(
+            list(
+                islice(
+                    cycle(
+                        [
+                            "#377eb8",
+                            "#ff7f00",
+                            "#4daf4a",
+                            "#f781bf",
+                            "#a65628",
+                            "#984ea3",
+                            "#999999",
+                            "#e41a1c",
+                            "#dede00",
+                        ]
+                    ),
+                    int(max(y_pred) + 1),
+                )
+            )
+        )
+        # add black color for outliers (if any)
+        colors = np.append(colors, ["#000000"])
+        plt.scatter(X[:, 0], X[:, 1], s=10, color=colors[y_pred])
+
+        plt.xlim(-2.5, 2.5)
+        plt.ylim(-2.5, 2.5)
+        plt.xticks(())
+        plt.yticks(())
+        plt.text(
+            0.99,
+            0.01,
+            ("%.2fs" % (t1 - t0)).lstrip("0"),
+            transform=plt.gca().transAxes,
+            size=15,
+            horizontalalignment="right",
+        )
+        plot_num += 1
+
+plt.savefig("img/comparison_of_elementary_methods.jpg")
diff --git a/scripts/generate_model_results_snapshot.py b/scripts/generate_model_results_snapshot.py
new file mode 100644
index 0000000..60e6fee
--- /dev/null
+++ b/scripts/generate_model_results_snapshot.py
@@ -0,0 +1,102 @@
+import pickle
+from pathlib import Path
+from sklearn.datasets import make_blobs
+import numpy as np
+from artlib import (
+    ART1,
+    ART2A,
+    BayesianART,
+    DualVigilanceART,
+    EllipsoidART,
+    FuzzyART,
+    GaussianART,
+    HypersphereART,
+    QuadraticNeuronART,
+)
+
+
+def model_factory(model_class, params):
+    """
+    A factory function to initialize models, handling special cases.
+    """
+    if model_class.__name__ == "DualVigilanceART":
+        # For DualVigilanceART, initialize with a base ART model
+        base_art = FuzzyART(params["rho"], params["alpha"], params["beta"])
+        return model_class(base_art, params["rho_lower_bound"])
+
+    # Default case for other models
+    return model_class(**params)
+
+
+def cluster_and_store_results():
+    # Generate blob data for clustering
+    data, _ = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
+    print("Data has shape:", data.shape)
+
+    # Define the models and their parameters in a list of tuples
+    models_with_params = [
+        # (ART1, {"rho": 0.7, "beta": 1.0, "L": 1.0}),
+        (ART2A, {"rho": 0.2, "alpha": 0.0, "beta": 1.0}),
+        (
+            BayesianART,
+            {"rho": 7e-5, "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]])},
+        ),
+        (
+            DualVigilanceART,
+            {"rho": 0.85, "alpha": 0.8, "beta": 1.0, "rho_lower_bound": 0.78},
+        ),
+        (
+            EllipsoidART,
+            {"rho": 0.01, "alpha": 0.0, "beta": 1.0, "r_hat": 0.65, "mu": 0.8},
+        ),
+        (FuzzyART, {"rho": 0.5, "alpha": 0.0, "beta": 1.0}),
+        (GaussianART, {"rho": 0.05, "sigma_init": np.array([0.5, 0.5])}),
+        (HypersphereART, {"rho": 0.5, "alpha": 0.0, "beta": 1.0, "r_hat": 0.8}),
+        (
+            QuadraticNeuronART,
+            {"rho": 0.9, "s_init": 0.9, "lr_b": 0.1, "lr_w": 0.1, "lr_s": 0.1},
+        ),
+    ]
+
+    results = {}
+
+    for model_class, params in models_with_params:
+        # Instantiate the model
+        cls = model_factory(model_class, params)
+        model_name = model_class.__name__
+
+        # Prepare data
+        X = cls.prepare_data(data)
+        print(f"Prepared data for {model_name} has shape:", X.shape)
+
+        # Fit the model and predict clusters
+        labels = cls.fit_predict(X)
+
+        # Store the labels and params in a dictionary keyed by the model name
+        results[model_name] = {"params": params, "labels": labels}
+        print(f"{cls.n_clusters} clusters found for {model_name}")
+
+    # Save the results to a pickle file
+    current_file_path = Path(__file__).resolve().parent.parent
+    output_file = (
+        current_file_path / "unit_tests" / "cluster_results_snapshot.pkl"
+    )
+
+    # Ensure the output directory exists
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+
+    # Save the results to the pickle file
+    with open(output_file, "wb") as f:
+        pickle.dump(results, f)
+
+    print("Results saved to cluster_results.pkl")
+
+
+if __name__ == "__main__":
+    cluster_and_store_results()
diff --git a/scripts/generate_references.py b/scripts/generate_references.py
new file mode 100644
index 0000000..51f0404
--- /dev/null
+++ b/scripts/generate_references.py
@@ -0,0 +1,39 @@
+import subprocess
+import os
+
+
+def generate_references():
+    try:
+        # Run cffconvert and capture the output
+        result = subprocess.run(
+            ["cffconvert", "--format", "bibtex", "--infile", "CITATION.cff"],
+            check=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            text=True,
+        )
+        bibtex_content = result.stdout
+
+        # Modify the BibTeX entry
+        # Replace the first line with the desired entry type and citation key
+        bibtex_lines = bibtex_content.splitlines()
+        if bibtex_lines:
+            # Replace the first line
+            bibtex_lines[0] = "@misc{Melton_AdaptiveResonanceLib_2024,"
+            modified_bibtex = "\n".join(bibtex_lines)
+
+            # Write the modified content to references.bib
+            output_path = os.path.join("docs", "source", "artlib_citation.bib")
+            with open(output_path, "w", encoding="utf-8") as f:
+                f.write(modified_bibtex)
+        else:
+            print("Error: Empty BibTeX content.")
+    except subprocess.CalledProcessError as e:
+        print(f"An error occurred while running cffconvert: {e}")
+        print(f"stdout: {e.stdout}")
+        print(f"stderr: {e.stderr}")
+        raise e
+
+
+if __name__ == "__main__":
+    generate_references()
diff --git a/scripts/optimization_of_methods.py b/scripts/optimization_of_methods.py
new file mode 100644
index 0000000..6ecfc2d
--- /dev/null
+++ b/scripts/optimization_of_methods.py
@@ -0,0 +1,209 @@
+import time
+import warnings
+from itertools import cycle, islice
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+from sklearn import datasets
+from sklearn.preprocessing import StandardScaler
+from sklearn.model_selection import GridSearchCV
+
+import artlib
+
+# ============
+# Generate datasets. We choose the size big enough to see the scalability
+# of the algorithms, but not too big to avoid too long running times
+# ============
+n_samples = 500
+seed = 30
+noisy_circles = datasets.make_circles(
+    n_samples=n_samples,
+    factor=0.5,
+    noise=0.05,
+    random_state=seed,
+    shuffle=False,
+)
+noisy_moons = datasets.make_moons(
+    n_samples=n_samples, noise=0.05, random_state=seed, shuffle=False
+)
+blobs = datasets.make_blobs(
+    n_samples=n_samples, random_state=seed, shuffle=False
+)
+rng = np.random.RandomState(seed)
+no_structure = rng.rand(n_samples, 2), None
+
+# Anisotropicly distributed data
+random_state = 170
+X, y = datasets.make_blobs(
+    n_samples=n_samples, random_state=random_state, shuffle=False
+)
+transformation = [[0.6, -0.6], [-0.4, 0.8]]
+X_aniso = np.dot(X, transformation)
+aniso = (X_aniso, y)
+
+# blobs with varied variances
+varied = datasets.make_blobs(
+    n_samples=n_samples,
+    cluster_std=[1.0, 2.5, 0.5],
+    random_state=random_state,
+    shuffle=False,
+)
+
+# ============
+# Set up cluster parameters
+# ============
+
+init_params = {
+    "BayesianART": {
+        "rho": 2e-5,
+        "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+    },
+    "DualVigilanceFuzzyART": {
+        "FuzzyART": {"rho": 0.95, "alpha": 0.8, "beta": 1.0},
+        "rho_lower_bound": 0.5,
+        "base_module": artlib.FuzzyART(0.0, 0.0, 1.0),
+    },
+    "EllipsoidART": {
+        "rho": 0.2,
+        "alpha": 0.0,
+        "beta": 1.0,
+        "r_hat": 0.6,
+        "mu": 0.8,
+    },
+    "FuzzyART": {"rho": 0.7, "alpha": 0.0, "beta": 1.0},
+    "GaussianART": {
+        "rho": 0.15,
+        "sigma_init": np.array([0.5, 0.5]),
+    },
+    "HypersphereART": {"rho": 0.6, "alpha": 0.0, "beta": 1.0, "r_hat": 0.8},
+    "QuadraticNeuronART": {
+        "rho": 0.99,
+        "s_init": 0.8,
+        "lr_b": 0.2,
+        "lr_w": 0.1,
+        "lr_s": 0.2,
+    },
+}
+
+rho_range = np.linspace(0.1, 1.0, 28)
+rho_range[-1] = 0.99
+alpha_range = np.linspace(0.0, 1.0, 21)
+
+
+param_grids = {
+    "BayesianART": {
+        "rho": [1e-5, 2e-5, 3e-5, 4e-5, 5e-5, 6e-5, 7e-5, 8e-5, 9e-5],
+        "cov_init": [np.array([[1e-4, 0.0], [0.0, 1e-4]])],
+    },
+    "DualVigilanceFuzzyART": {
+        "base_module": [artlib.FuzzyART(0.1, 0.0, 1.0)],
+        "base_module__rho": rho_range,
+        "base_module__alpha": alpha_range,
+        "base_module__beta": [1.0],
+        "rho_lower_bound": rho_range,
+    },
+    "EllipsoidART": {
+        "rho": rho_range,
+        "alpha": alpha_range,
+        "beta": [1.0],
+        "r_hat": np.linspace(0.1, 0.9, 17),
+        "mu": np.linspace(0.1, 1.0, 10),
+    },
+    "FuzzyART": {"rho": rho_range, "alpha": alpha_range, "beta": [1.0]},
+    "GaussianART": {
+        "rho": rho_range,
+        "sigma_init": [np.array([0.5, 0.5])],
+    },
+    "HypersphereART": {
+        "rho": rho_range,
+        "alpha": alpha_range,
+        "beta": [1.0],
+        "r_hat": np.linspace(0.1, 0.8, 22),
+    },
+    "QuadraticNeuronART": {
+        "rho": rho_range,
+        "s_init": np.linspace(0.1, 1.0, 10),
+        "lr_b": np.linspace(0.1, 0.6, 6),
+        "lr_w": np.linspace(0.1, 0.6, 6),
+        "lr_s": np.linspace(0.1, 0.6, 6),
+    },
+}
+
+plot_num = 1
+
+datasets = [
+    (
+        "noisy_circles",
+        noisy_circles,
+    ),
+    (
+        "noisy_moons",
+        noisy_moons,
+    ),
+    (
+        "varied",
+        varied,
+    ),
+    (
+        "aniso",
+        aniso,
+    ),
+    (
+        "blobs",
+        blobs,
+    ),
+    (
+        "no_structure",
+        no_structure,
+    ),
+]
+
+for i_dataset, (dataset_name, dataset) in enumerate(datasets):
+    X, y = dataset
+
+    # normalize dataset for easier parameter selection
+    X = StandardScaler().fit_transform(X)
+
+    # ============
+    # Create cluster objects
+    # ============
+    print(dataset_name)
+    hypersphere = artlib.HypersphereART(**init_params["HypersphereART"])
+    ellipsoid = artlib.EllipsoidART(**init_params["EllipsoidART"])
+    gaussian = artlib.GaussianART(**init_params["GaussianART"])
+    bayesian = artlib.BayesianART(**init_params["BayesianART"])
+    quadratic_neuron = artlib.QuadraticNeuronART(
+        **init_params["QuadraticNeuronART"]
+    )
+    fuzzy = artlib.FuzzyART(**init_params["FuzzyART"])
+    fuzzyDV = artlib.DualVigilanceART(
+        artlib.FuzzyART(**init_params["FuzzyART"]),
+        rho_lower_bound=init_params["DualVigilanceFuzzyART"]["rho_lower_bound"],
+    )
+
+    clustering_algorithms = (
+        ("DualVigilanceFuzzyART", fuzzyDV),
+        ("HypersphereART", hypersphere),
+        ("EllipsoidART", ellipsoid),
+        ("GaussianART", gaussian),
+        ("BayesianART", bayesian),
+        ("QuadraticNeuronART", quadratic_neuron),
+        ("FuzzyART", fuzzy),
+    )
+
+    for name, algorithm in clustering_algorithms:
+        print("\t", name)
+        grid = GridSearchCV(
+            algorithm,
+            param_grids[name],
+            refit=False,
+            verbose=0,
+            n_jobs=-1,
+            scoring="adjusted_rand_score",
+            cv=[(np.array(list(range(len(X)))), np.array(list(range(len(X)))))],
+        )
+        X_prepared = algorithm.prepare_data(X)
+        grid.fit(X_prepared, y)
+        print("\tBest parameters:")
+        print("\t\t", grid.best_params_)
diff --git a/templates/ART_template.py b/templates/ART_template.py
index ba74056..7fcd79b 100644
--- a/templates/ART_template.py
+++ b/templates/ART_template.py
@@ -1,111 +1,179 @@
+"""Template for custom ART Modules.
+
+This template contains the minimum methods necessary to define for a custom ART
+module. Additional functions may be defined as well depending on how customized the
+behavior of the model is. See :class:`~artlib.common.BaseART.BaseART` for the
+complete set of pre-defined methods.
+
+"""
 import numpy as np
-from typing import Optional
-from artlib import BaseART
-from artlib.common.utils import normalize
+from matplotlib.axes import Axes
+from typing import Optional, List, Tuple, Dict
 
-def prepare_data(data: np.ndarray) -> np.ndarray:
-    normalized = normalize(data)
-    return normalized
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import IndexableOrKeyable
 
 
-class myART(BaseART):
-    # template for ART module
+class MyART(BaseART):
+    """Generic Template for custom ART modules."""
 
-    @staticmethod
-    def validate_params(params: dict):
-        """
-        validate clustering parameters
+    def __init__(self, rho: float):
+        """Initializes the ART object.
 
-        Parameters:
-        - params: dict containing parameters for the algorithm
+        Parameters
+        ----------
+        rho : float
+            Vigilance parameter.
 
         """
-        raise NotImplementedError
+        # add additional parameters as needed
+        params = {"rho": rho}
+        super().__init__(params)
 
-    def validate_data(self, X: np.ndarray):
-        """
-        validates the data prior to clustering
+    @staticmethod
+    def validate_params(params: dict):
+        """Validates clustering parameters.
 
-        Parameters:
-        - X: data set
+        Parameters
+        ----------
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
         """
-        raise NotImplementedError
+        assert "rho" in params
+        assert params["rho"] >= 0.0
+        assert isinstance(params["rho"], float)
+        # check additional parameters if you add them
+
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: Dict
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the activation of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+
+        Returns
+        -------
+        tuple
+            Cluster activation and cache used for later processing.
 
-    def category_choice(self, i: np.ndarray, w: np.ndarray, params: dict) -> tuple[float, Optional[dict]]:
         """
-        get the activation of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+        # implement your custom logic here
+        raise NotImplementedError
 
-        Returns:
-            cluster activation, cache used for later processing
+    def match_criterion(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+    ) -> Tuple[float, Optional[Dict]]:
+        """Get the match criterion of the cluster.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        tuple
+            Cluster match criterion and cache used for later processing.
 
         """
+        # implement your custom logic here
         raise NotImplementedError
 
-    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[float, dict]:
-        """
-        get the match criterion of the cluster
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
-
-        Returns:
-            cluster match criterion, cache used for later processing
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: Dict,
+        cache: Optional[Dict] = None,
+    ) -> np.ndarray:
+        """Get the updated cluster weight.
+
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        w : np.ndarray
+            Cluster weight or information.
+        params : dict
+            Dictionary containing parameters for the algorithm.
+        cache : dict, optional
+            Cache containing values from previous calculations.
+
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
+        # implement your custom logic here
         raise NotImplementedError
 
-    def match_criterion_bin(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[bool, dict]:
-        """
-        get the binary match criterion of the cluster
+    def new_weight(self, i: np.ndarray, params: Dict) -> np.ndarray:
+        """Generate a new cluster weight.
 
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
+        Parameters
+        ----------
+        i : np.ndarray
+            Data sample.
+        params : dict
+            Dictionary containing parameters for the algorithm.
 
-        Returns:
-            cluster match criterion binary, cache used for later processing
+        Returns
+        -------
+        np.ndarray
+            Updated cluster weight.
 
         """
+        # implement your custom logic here
         raise NotImplementedError
 
-    def update(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> np.ndarray:
-        """
-        get the updated cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
-        - cache: dict containing values cached from previous calculations
+    # ==================================================================================
+    # These functions are not strictly necessary but should be defined if possible
+    # ==================================================================================
+    def get_cluster_centers(self) -> List[np.ndarray]:
+        """Undefined function for getting centers of each cluster. Used for regression.
 
-        Returns:
-            updated cluster weight, cache used for later processing
+        Returns
+        -------
+        list of np.ndarray
+            Cluster centroids.
 
         """
+        # implement your custom logic here
         raise NotImplementedError
 
-    def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
-        """
-        generate a new cluster weight
-
-        Parameters:
-        - i: data sample
-        - w: cluster weight / info
-        - params: dict containing parameters for the algorithm
+    def plot_cluster_bounds(
+        self, ax: Axes, colors: IndexableOrKeyable, linewidth: int = 1
+    ):
+        """Undefined function for visualizing the bounds of each cluster.
 
-        Returns:
-            updated cluster weight
+        Parameters
+        ----------
+        ax : matplotlib.axes.Axes
+            Figure axes.
+        colors : IndexableOrKeyable
+            Colors to use for each cluster.
+        linewidth : int, default=1
+            Width of boundary line.
 
         """
+        # implement your custom logic here
         raise NotImplementedError


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..7fb5907
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,37 @@
+name: Run Python Unit Tests
+
+on:
+  pull_request:
+    branches:
+      - main
+      - develop
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+
+    - name: Set up Python
+      uses: actions/setup-python@v4
+      with:
+        python-version: '3.9'
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install poetry
+        poetry install --with dev  # Install dependencies from pyproject.toml, including dev dependencies
+
+    - name: Run tests
+      run: |
+        poetry run pytest unit_tests/ --maxfail=5 --disable-warnings -v --tb=short  # Add verbose and short traceback options
+
+    - name: Publish test results
+      if: failure()
+      uses: actions/upload-artifact@v3
+      with:
+        name: pytest-results
+        path: ./test-results/pytest-report.xml
diff --git a/examples/test_convex_hull_art.py b/examples/test_convex_hull_art.py
deleted file mode 100644
index 88c2073..0000000
--- a/examples/test_convex_hull_art.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from sklearn.datasets import make_blobs
-import matplotlib.pyplot as plt
-import numpy as np
-
-from artlib.experimental.ConvexHullART import ConvexHullART, plot_convex_polygon
-from scipy.spatial import ConvexHull
-
-
-def cluster_blobs():
-    data, target = make_blobs(n_samples=150, centers=3, cluster_std=0.50, random_state=0, shuffle=False)
-    print("Data has shape:", data.shape)
-
-
-    params = {
-        "rho": 0.85,
-        "merge_rho": 0.8
-    }
-    cls = ConvexHullART(**params)
-
-    X = cls.prepare_data(data)
-    print("Prepared data has shape:", X.shape)
-
-    y = cls.fit_predict(X)
-
-    print(f"{cls.n_clusters} clusters found")
-
-    cls.visualize(X, y)
-    plt.show()
-
-
-if __name__ == "__main__":
-    cluster_blobs()
diff --git a/unit_tests/cluster_results_snapshot.pkl b/unit_tests/cluster_results_snapshot.pkl
new file mode 100644
index 0000000..be38a19
Binary files /dev/null and b/unit_tests/cluster_results_snapshot.pkl differ
diff --git a/unit_tests/test_ART1.py b/unit_tests/test_ART1.py
new file mode 100644
index 0000000..bd317bc
--- /dev/null
+++ b/unit_tests/test_ART1.py
@@ -0,0 +1,141 @@
+import pytest
+import numpy as np
+from artlib.elementary.ART1 import ART1
+
+
+# Fixture to initialize an ART1 instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    L = 2.0
+    return ART1(rho=rho, L=L)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert art_model.params["L"] == 2.0
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"rho": 0.7, "L": 2.0}
+    ART1.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": -0.7,  # Invalid vigilance parameter
+        "L": 0.5,  # Invalid L (must be >= 1)
+    }
+    with pytest.raises(AssertionError):
+        ART1.validate_params(invalid_params)
+
+
+def test_validate_data(art_model):
+    # Test the validate_data method
+    binary_data = np.array([[1, 0], [0, 1]])
+    art_model.validate_data(binary_data)
+
+    non_binary_data = np.array([[0.5, 1.0], [1.2, 0.3]])
+    with pytest.raises(AssertionError):
+        art_model.validate_data(non_binary_data)
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([1, 0])
+    w = np.array(
+        [0.5, 0.5, 1, 0]
+    )  # Mock weight with both bottom-up and top-down weights
+    params = {"rho": 0.7}
+
+    activation, _ = art_model.category_choice(i, w, params)
+    assert isinstance(activation, float)
+    assert activation == 0.5  # np.dot(i, [0.5, 0.5]) = 0.5
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    art_model.dim_ = 2
+    i = np.array([1, 0])
+    w = np.array(
+        [0.5, 0.5, 1, 0]
+    )  # Mock weight with both bottom-up and top-down weights
+    params = {"rho": 0.7}
+    cache = {}
+
+    match_criterion, _ = art_model.match_criterion(i, w, params, cache=cache)
+    assert isinstance(match_criterion, float)
+    assert (
+        match_criterion == 1.0
+    )  # Intersection of i and top-down weight w_td: [1, 0] matches exactly with i
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([1, 0])
+    w = np.array(
+        [0.5, 0.5, 1, 1]
+    )  # Mock weight with both bottom-up and top-down weights
+    params = {"L": 2.0}
+
+    updated_weight = art_model.update(i, w, params)
+    assert len(updated_weight) == 4  # Bottom-up and top-down weights
+    assert np.array_equal(
+        updated_weight[2:], np.array([1, 0])
+    )  # Top-down weights should match input i
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    art_model.dim_ = 2
+    i = np.array([1, 0])
+    params = {"L": 2.0}
+
+    new_weight = art_model.new_weight(i, params)
+    assert len(new_weight) == 4  # Bottom-up and top-down weights
+    assert np.array_equal(
+        new_weight[2:], i
+    )  # Top-down weights should be equal to input i
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.dim_ = 2
+    art_model.W = [np.array([0.5, 0.5, 1, 0]), np.array([0.3, 0.7, 0, 1])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 2
+    assert np.array_equal(centers[0], np.array([1, 0]))
+    assert np.array_equal(centers[1], np.array([0, 1]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    art_model.dim_ = 2
+    X = np.array([[1, 0], [0, 1], [1, 1]])
+    art_model.check_dimensions(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    art_model.dim_ = 2
+    X = np.array([[1, 0], [0, 1]])
+    art_model.check_dimensions(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    art_model.dim_ = 2
+    X = np.array([[1, 0], [0, 1]])
+    art_model.check_dimensions(X)
+    art_model.W = [np.array([0.5, 0.5, 1, 0]), np.array([0.3, 0.7, 0, 1])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_ART2.py b/unit_tests/test_ART2.py
new file mode 100644
index 0000000..c2f0297
--- /dev/null
+++ b/unit_tests/test_ART2.py
@@ -0,0 +1,134 @@
+import pytest
+import numpy as np
+from artlib.elementary.ART2 import ART2A
+
+
+# Fixture to initialize an ART2A instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    alpha = 0.1
+    beta = 0.5
+    return ART2A(rho=rho, alpha=alpha, beta=beta)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert art_model.params["alpha"] == 0.1
+    assert art_model.params["beta"] == 0.5
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"rho": 0.7, "alpha": 0.1, "beta": 0.5}
+    ART2A.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": -0.7,  # Invalid vigilance parameter
+        "alpha": -0.1,  # Invalid alpha
+        "beta": 1.5,  # Invalid beta
+    }
+    with pytest.raises(AssertionError):
+        ART2A.validate_params(invalid_params)
+
+
+def test_check_dimensions(art_model):
+    # Test the check_dimensions method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+
+    assert art_model.dim_ == 2
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35])  # Mock weight
+    params = {"rho": 0.7}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "activation" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35])  # Mock weight
+    params = {"alpha": 0.1}
+    cache = {"activation": 0.5}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    assert (
+        match_criterion == 0.5
+    )  # Since activation is higher than uncommitted activation
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35])  # Mock weight
+    params = {"beta": 0.5}
+    cache = {"activation": 0.5}
+
+    updated_weight = art_model.update(i, w, params, cache=cache)
+    assert len(updated_weight) == 2  # Check that the weight is updated
+    assert np.allclose(
+        updated_weight, (0.5 * i + 0.5 * w)
+    )  # Check the update formula
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    i = np.array([0.2, 0.3])
+    params = {"beta": 0.5}
+
+    new_weight = art_model.new_weight(i, params)
+    assert len(new_weight) == 2  # Check that the weight has two dimensions
+    assert np.array_equal(new_weight, i)
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.W = [np.array([0.2, 0.3]), np.array([0.4, 0.5])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 2
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+    assert np.array_equal(centers[1], np.array([0.4, 0.5]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    art_model.check_dimensions(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+    art_model.W = [np.array([0.1, 0.2]), np.array([0.3, 0.4])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_ARTMAP.py b/unit_tests/test_ARTMAP.py
new file mode 100644
index 0000000..125f83e
--- /dev/null
+++ b/unit_tests/test_ARTMAP.py
@@ -0,0 +1,138 @@
+import pytest
+import numpy as np
+from artlib.supervised.ARTMAP import ARTMAP
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+
+
+# Fixture to initialize an ARTMAP instance for testing
+@pytest.fixture
+def artmap_model():
+    module_a = FuzzyART(0.5, 0.01, 1.0)
+    module_b = FuzzyART(0.5, 0.01, 1.0)
+    return ARTMAP(module_a=module_a, module_b=module_b)
+
+
+def test_initialization(artmap_model):
+    # Test that the model initializes correctly
+    assert isinstance(artmap_model.module_a, BaseART)
+    assert isinstance(artmap_model.module_b, BaseART)
+
+
+def test_get_params(artmap_model):
+    # Test the get_params method
+    params = artmap_model.get_params()
+    assert "module_a" in params
+    assert "module_b" in params
+
+
+def test_labels_properties(artmap_model):
+    # Test the labels properties
+    artmap_model.module_a.labels_ = np.array([0, 1, 1])
+    artmap_model.module_b.labels_ = np.array([1, 0, 0])
+
+    assert np.array_equal(artmap_model.labels_a, artmap_model.module_a.labels_)
+    assert np.array_equal(artmap_model.labels_b, artmap_model.module_b.labels_)
+    assert artmap_model.labels_ab == {
+        "A": artmap_model.module_a.labels_,
+        "B": artmap_model.module_b.labels_,
+    }
+
+
+def test_validate_data(artmap_model):
+    # Test the validate_data method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+
+    artmap_model.validate_data(X_prep, y_prep)
+
+    # Test invalid input data
+    X_invalid = np.random.rand(0, 5)
+    with pytest.raises(AssertionError):
+        artmap_model.validate_data(X_invalid, y)
+
+
+def test_prepare_and_restore_data(artmap_model):
+    # Test prepare_data and restore_data methods
+    np.random.seed(42)
+    X = np.random.rand(5, 5)
+    y = np.random.rand(5, 5)
+
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+
+    X_restored, y_restored = artmap_model.restore_data(X_prep, y_prep)
+    assert np.allclose(X_restored, X)
+    assert np.allclose(y_restored, y)
+
+
+def test_fit(artmap_model):
+    # Test the fit method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+
+    # Prepare data before fitting
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+    artmap_model.fit(X_prep, y_prep, max_iter=1)
+
+    assert artmap_model.module_a.labels_.shape[0] == X.shape[0]
+    assert artmap_model.module_b.labels_.shape[0] == y.shape[0]
+
+
+def test_partial_fit(artmap_model):
+    # Test the partial_fit method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+
+    # Prepare data before partial fitting
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+    artmap_model.partial_fit(X_prep, y_prep)
+
+    assert artmap_model.module_a.labels_.shape[0] == X.shape[0]
+    assert artmap_model.module_b.labels_.shape[0] == y.shape[0]
+
+
+def test_predict(artmap_model):
+    # Test the predict method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+
+    # Prepare data before fitting and predicting
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+    artmap_model.fit(X_prep, y_prep, max_iter=1)
+
+    predictions = artmap_model.predict(X_prep)
+    assert predictions.shape[0] == X.shape[0]
+
+
+def test_predict_ab(artmap_model):
+    # Test the predict_ab method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+
+    # Prepare data before fitting and predicting
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+    artmap_model.fit(X_prep, y_prep, max_iter=1)
+
+    predictions_a, predictions_b = artmap_model.predict_ab(X_prep)
+    assert predictions_a.shape[0] == X.shape[0]
+    assert predictions_b.shape[0] == X.shape[0]
+
+
+def test_predict_regression(artmap_model):
+    # Test the predict_regression method
+    np.random.seed(42)
+    X = np.random.rand(10, 5)
+    y = np.random.rand(10, 5)
+
+    # Prepare data before fitting and predicting regression
+    X_prep, y_prep = artmap_model.prepare_data(X, y)
+    artmap_model.fit(X_prep, y_prep, max_iter=1)
+
+    regression_preds = artmap_model.predict_regression(X_prep)
+    assert regression_preds.shape[0] == X.shape[0]
diff --git a/unit_tests/test_BARTMAP.py b/unit_tests/test_BARTMAP.py
new file mode 100644
index 0000000..aa6715a
--- /dev/null
+++ b/unit_tests/test_BARTMAP.py
@@ -0,0 +1,105 @@
+import pytest
+import numpy as np
+from typing import Optional
+from artlib.biclustering.BARTMAP import BARTMAP
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+
+
+# Fixture to initialize a BARTMAP instance for testing
+@pytest.fixture
+def bartmap_model():
+    module_a = FuzzyART(0.5, 0.01, 1.0)
+    module_b = FuzzyART(0.5, 0.01, 1.0)
+    return BARTMAP(module_a=module_a, module_b=module_b, eta=0.01)
+
+
+def test_initialization(bartmap_model):
+    # Test that the model initializes correctly
+    assert bartmap_model.params["eta"] == 0.01
+    assert isinstance(bartmap_model.module_a, BaseART)
+    assert isinstance(bartmap_model.module_b, BaseART)
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"eta": 0.5}
+    BARTMAP.validate_params(valid_params)
+
+    invalid_params = {"eta": "invalid"}  # eta should be a float
+    with pytest.raises(AssertionError):
+        BARTMAP.validate_params(invalid_params)
+
+
+def test_get_params(bartmap_model):
+    # Test the get_params method
+    params = bartmap_model.get_params()
+    assert "eta" in params
+    assert "module_a" in params
+    assert "module_b" in params
+
+
+def test_set_params(bartmap_model):
+    # Test the set_params method
+    bartmap_model.set_params(eta=0.7)
+    assert bartmap_model.eta == 0.7
+
+
+def test_step_fit(bartmap_model):
+    # Test the step_fit method
+    X = np.random.rand(10, 10)
+
+    bartmap_model.X = X
+
+    X_a = bartmap_model.module_a.prepare_data(X)
+    X_b = bartmap_model.module_b.prepare_data(X.T)
+
+    bartmap_model.module_b = bartmap_model.module_b.fit(X_b, max_iter=1)
+
+    # init module A
+    bartmap_model.module_a.W = []
+    bartmap_model.module_a.labels_ = np.zeros((X.shape[0],), dtype=int)
+
+    c_a = bartmap_model.step_fit(X_a, 0)
+    assert isinstance(c_a, int)  # Ensure the result is an integer cluster label
+
+
+def test_match_criterion_bin(bartmap_model):
+    # Test the match_criterion_bin method
+    X = np.random.rand(100, 100)
+
+    bartmap_model.X = X
+
+    X_a = bartmap_model.module_a.prepare_data(X)
+    X_b = bartmap_model.module_b.prepare_data(X.T)
+
+    bartmap_model.module_b = bartmap_model.module_b.fit(X_b, max_iter=1)
+
+    # init module A
+    bartmap_model.module_a.W = []
+    bartmap_model.module_a.labels_ = np.zeros((X.shape[0],), dtype=int)
+    c_a = bartmap_model.step_fit(X_a, 0)
+
+    result = bartmap_model.match_criterion_bin(X, 9, 0, {"eta": 0.5})
+    assert isinstance(result, bool)  # Ensure the result is a boolean
+
+
+def test_fit(bartmap_model):
+    # Test the fit method
+    X = np.random.rand(100, 100)
+
+    bartmap_model.fit(X, max_iter=1)
+
+    # Check that rows_ and columns_ are set
+    assert hasattr(bartmap_model, "rows_")
+    assert hasattr(bartmap_model, "columns_")
+
+    # Check that the rows and columns shapes match the expected size
+    assert (
+        bartmap_model.rows_.shape[0]
+        == bartmap_model.module_a.n_clusters * bartmap_model.module_b.n_clusters
+    )
+    assert (
+        bartmap_model.columns_.shape[0]
+        == bartmap_model.module_a.n_clusters * bartmap_model.module_b.n_clusters
+    )
diff --git a/unit_tests/test_BayesianART.py b/unit_tests/test_BayesianART.py
new file mode 100644
index 0000000..f0fb4be
--- /dev/null
+++ b/unit_tests/test_BayesianART.py
@@ -0,0 +1,141 @@
+import pytest
+import numpy as np
+from artlib.elementary.BayesianART import BayesianART
+
+
+# Fixture to initialize a BayesianART instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    cov_init = np.array([[1.0, 0.0], [0.0, 1.0]])  # Initial covariance matrix
+    return BayesianART(rho=rho, cov_init=cov_init)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert np.array_equal(
+        art_model.params["cov_init"], np.array([[1.0, 0.0], [0.0, 1.0]])
+    )
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"rho": 0.7, "cov_init": np.array([[1.0, 0.0], [0.0, 1.0]])}
+    BayesianART.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": -0.5,  # Invalid vigilance parameter
+        "cov_init": "not_a_matrix",  # Invalid covariance matrix
+    }
+    with pytest.raises(AssertionError):
+        BayesianART.validate_params(invalid_params)
+
+
+def test_check_dimensions(art_model):
+    # Test the check_dimensions method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+
+    assert art_model.dim_ == 2
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 1.0, 0.0, 0.0, 1.0, 5]
+    )  # Mock weight (mean, covariance, and sample count)
+    art_model.W = [w]
+    params = {"rho": 0.7}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "cov" in cache
+    assert "det_cov" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 1.0, 0.0, 0.0, 1.0, 5]
+    )  # Mock weight (mean, covariance, and sample count)
+    params = {"rho": 0.7}
+    cache = {}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    assert isinstance(match_criterion, float)
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 1.0, 0.0, 0.0, 1.0, 5]
+    )  # Mock weight (mean, covariance, and sample count)
+    params = {"rho": 0.7}
+    cache = {}
+
+    updated_weight = art_model.update(i, w, params, cache=cache)
+    assert (
+        len(updated_weight) == 7
+    )  # Mean (2D), covariance (4 values), and sample count
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    params = {"cov_init": np.array([[1.0, 0.0], [0.0, 1.0]])}
+
+    new_weight = art_model.new_weight(i, params)
+    assert (
+        len(new_weight) == 7
+    )  # Mean (2D), covariance (4 values), and sample count
+    assert new_weight[-1] == 1  # Initial sample count should be 1
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.dim_ = 2
+    art_model.W = [np.array([0.2, 0.3, 1.0, 0.0, 0.0, 1.0, 5])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    art_model.check_dimensions(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)
+    art_model.W = [np.array([0.1, 0.2, 1.0, 0.0, 0.0, 1.0, 5])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_CVIART.py b/unit_tests/test_CVIART.py
new file mode 100644
index 0000000..3211a1d
--- /dev/null
+++ b/unit_tests/test_CVIART.py
@@ -0,0 +1,99 @@
+import pytest
+import numpy as np
+from sklearn.metrics import (
+    calinski_harabasz_score,
+    davies_bouldin_score,
+    silhouette_score,
+)
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+from artlib.cvi.CVIART import CVIART
+
+
+@pytest.fixture
+def cviart_model():
+    # Initialize CVIART with a FuzzyART base module and Calinski-Harabasz validity index
+    base_module = FuzzyART(0.5, 0.01, 1.0)
+    return CVIART(base_module=base_module, validity=CVIART.CALINSKIHARABASZ)
+
+
+def test_cviart_initialization(cviart_model):
+    # Test that the CVIART model initializes correctly
+    assert isinstance(cviart_model.base_module, BaseART)
+    assert cviart_model.params["validity"] == CVIART.CALINSKIHARABASZ
+
+
+def test_cviart_validate_params():
+    # Test the validate_params method
+    base_module = FuzzyART(0.5, 0.01, 1.0)
+    cviart_model = CVIART(base_module=base_module, validity=CVIART.SILHOUETTE)
+    valid_params = dict(base_module.params)
+    valid_params.update({"validity": CVIART.SILHOUETTE})
+    cviart_model.validate_params(valid_params)
+
+    invalid_params = {"validity": 999}  # Invalid validity index
+    with pytest.raises(AssertionError):
+        cviart_model.validate_params(invalid_params)
+
+
+def test_cviart_prepare_and_restore_data(cviart_model):
+    # Test prepare_data and restore_data methods
+    X = np.random.rand(10, 4)
+
+    X_prep = cviart_model.prepare_data(X)
+
+    X_restored = cviart_model.restore_data(X_prep)
+    assert np.allclose(X_restored, X)
+
+
+def test_cviart_fit(cviart_model):
+    # Test the fit method of CVIART
+    X = np.random.rand(10, 4)
+    X_prep = cviart_model.prepare_data(X)
+
+    cviart_model.fit(X_prep, max_iter=1)
+
+    assert len(cviart_model.W) > 0
+    assert cviart_model.labels_.shape[0] == X.shape[0]
+
+
+def test_cviart_step_fit_not_implemented(cviart_model):
+    # Test that the step_fit method raises NotImplementedError
+    X = np.random.rand(10, 4)
+
+    with pytest.raises(NotImplementedError):
+        cviart_model.step_fit(X[0])
+
+
+def test_cviart_CVI_match(cviart_model):
+    # Test the CVI_match method with Calinski-Harabasz index
+    X = np.random.rand(10, 4)
+    cviart_model.data = X
+    cviart_model.labels_ = np.random.randint(0, 2, size=(10,))
+
+    x = X[0]
+    w = np.random.rand(4)
+    cviart_model.base_module.W = w
+
+    # Test that CVI_match correctly works with Calinski-Harabasz
+    result = cviart_model.CVI_match(
+        x,
+        w,
+        1,
+        cviart_model.params,
+        {"validity": CVIART.CALINSKIHARABASZ, "index": 0},
+        {},
+    )
+    assert isinstance(result, np.bool_)
+
+
+def test_cviart_get_cluster_centers(cviart_model):
+    # Test the get_cluster_centers method
+    cviart_model.base_module.d_min_ = np.array([0.0, 0.0])
+    cviart_model.base_module.d_max_ = np.array([1.0, 1.0])
+    cviart_model.base_module.W = [np.array([0.1, 0.4, 0.5, 0.4])]
+
+    centers = cviart_model.get_cluster_centers()
+
+    assert len(centers) == 1
+    assert np.allclose(centers[0], np.array([0.3, 0.5]))
diff --git a/unit_tests/test_DeepARTMAP.py b/unit_tests/test_DeepARTMAP.py
new file mode 100644
index 0000000..b6669c1
--- /dev/null
+++ b/unit_tests/test_DeepARTMAP.py
@@ -0,0 +1,144 @@
+import pytest
+import numpy as np
+from artlib.hierarchical.DeepARTMAP import DeepARTMAP
+from artlib.supervised.SimpleARTMAP import SimpleARTMAP
+from artlib.supervised.ARTMAP import ARTMAP
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+
+
+# Fixture to initialize a DeepARTMAP instance for testing
+@pytest.fixture
+def deep_artmap_model():
+    module_a = FuzzyART(0.5, 0.01, 1.0)
+    module_b = FuzzyART(0.7, 0.01, 1.0)
+    return DeepARTMAP(modules=[module_a, module_b])
+
+
+def test_initialization(deep_artmap_model):
+    # Test that the model initializes correctly
+    assert isinstance(deep_artmap_model.modules[0], BaseART)
+    assert isinstance(deep_artmap_model.modules[1], BaseART)
+    assert len(deep_artmap_model.modules) == 2
+
+
+def test_get_params(deep_artmap_model):
+    # Test the get_params method
+    params = deep_artmap_model.get_params()
+    assert "module_0" in params
+    assert "module_1" in params
+
+
+def test_set_params(deep_artmap_model):
+    # Test the set_params method
+    deep_artmap_model.set_params(module_0__rho=0.6)
+    assert deep_artmap_model.modules[0].params["rho"] == 0.6
+
+
+def test_validate_data(deep_artmap_model):
+    # Test the validate_data method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+    y = np.random.randint(0, 2, size=10)
+    deep_artmap_model.validate_data(X, y)
+
+    # Test invalid input data
+    X_invalid = [np.random.rand(9, 5), np.random.rand(10, 5)]
+    with pytest.raises(AssertionError):
+        deep_artmap_model.validate_data(X_invalid, y)
+
+
+def test_prepare_and_restore_data(deep_artmap_model):
+    # Test prepare_data and restore_data methods
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+
+    X_restored, _ = deep_artmap_model.restore_data(X_prep)
+    assert np.allclose(X_restored[0], X[0])
+    assert np.allclose(X_restored[1], X[1])
+
+
+def test_fit_supervised(deep_artmap_model):
+    # Test the supervised fit method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.fit(X_prep, y, max_iter=1)
+
+    assert deep_artmap_model.layers[0].labels_.shape[0] == X[0].shape[0]
+
+
+def test_fit_unsupervised(deep_artmap_model):
+    # Test the unsupervised fit method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+
+    # Prepare data before fitting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.fit(X_prep, max_iter=1)
+
+    assert deep_artmap_model.layers[0].labels_a.shape[0] == X[0].shape[0]
+
+
+def test_partial_fit_supervised(deep_artmap_model):
+    # Test the supervised partial_fit method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before partial fitting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.partial_fit(X_prep, y)
+
+    assert deep_artmap_model.layers[0].labels_.shape[0] == X[0].shape[0]
+
+
+def test_partial_fit_unsupervised(deep_artmap_model):
+    # Test the unsupervised partial_fit method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+
+    # Prepare data before partial fitting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.partial_fit(X_prep)
+
+    assert deep_artmap_model.layers[0].labels_a.shape[0] == X[0].shape[0]
+
+
+def test_predict(deep_artmap_model):
+    # Test the predict method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+
+    # Prepare data before fitting and predicting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.fit(X_prep, max_iter=1)
+
+    predictions = deep_artmap_model.predict(X_prep)
+    assert predictions[-1].shape[0] == X[-1].shape[0]
+
+
+def test_labels_deep(deep_artmap_model):
+    # Test the labels_deep_ method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting and predicting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.fit(X_prep, y, max_iter=1)
+
+    labels_deep = deep_artmap_model.labels_deep_
+    assert labels_deep.shape == (10, 3)
+
+
+def test_map_deep(deep_artmap_model):
+    # Test the map_deep method
+    X = [np.random.rand(10, 5), np.random.rand(10, 5)]
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting
+    X_prep, _ = deep_artmap_model.prepare_data(X)
+    deep_artmap_model.fit(X_prep, y, max_iter=1)
+
+    mapped_label = deep_artmap_model.map_deep(
+        0, deep_artmap_model.layers[0].labels_a[0]
+    )
+    assert isinstance(mapped_label.tolist(), int)
diff --git a/unit_tests/test_DualVigilanceART.py b/unit_tests/test_DualVigilanceART.py
new file mode 100644
index 0000000..2c480ce
--- /dev/null
+++ b/unit_tests/test_DualVigilanceART.py
@@ -0,0 +1,143 @@
+import pytest
+import numpy as np
+from typing import Optional
+from artlib.topological.DualVigilanceART import DualVigilanceART
+from artlib.common.BaseART import BaseART
+
+
+# Mock BaseART class for testing purposes
+class MockBaseART(BaseART):
+    def __init__(self):
+        params = {"rho": 0.7}
+        super().__init__(params)
+        self.W = []
+        self.labels_ = np.array([])
+        self.dim_ = 2
+
+    @staticmethod
+    def validate_params(params: dict):
+        pass
+
+    def prepare_data(self, X: np.ndarray):
+        return X
+
+    def restore_data(self, X: np.ndarray):
+        return X
+
+    def new_weight(self, i: np.ndarray, params: dict) -> np.ndarray:
+        return i
+
+    def add_weight(self, w: np.ndarray):
+        self.W.append(w)
+
+    def category_choice(
+        self, i: np.ndarray, w: np.ndarray, params: dict
+    ) -> tuple[float, Optional[dict]]:
+        return np.random.random(), {}
+
+    def match_criterion_bin(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+        op=None,
+    ) -> tuple[bool, dict]:
+        return True, {}
+
+    def update(
+        self,
+        i: np.ndarray,
+        w: np.ndarray,
+        params: dict,
+        cache: Optional[dict] = None,
+    ) -> np.ndarray:
+        return w
+
+    def get_cluster_centers(self) -> list:
+        return [w for w in self.W]
+
+    def check_dimensions(self, X: np.ndarray):
+        assert X.shape[1] == self.dim_
+
+
+@pytest.fixture
+def art_model():
+    base_module = MockBaseART()
+    rho_lower_bound = 0.3
+    return DualVigilanceART(
+        base_module=base_module, rho_lower_bound=rho_lower_bound
+    )
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho_lower_bound"] == 0.3
+    assert isinstance(art_model.base_module, BaseART)
+
+
+def test_prepare_data(art_model):
+    # Test the prepare_data method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    prepared_X = art_model.prepare_data(X)
+    assert np.array_equal(prepared_X, X)
+
+
+def test_restore_data(art_model):
+    # Test the restore_data method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    restored_X = art_model.restore_data(X)
+    assert np.array_equal(restored_X, X)
+
+
+def test_get_params(art_model):
+    # Test the get_params method
+    params = art_model.get_params(deep=True)
+    assert "rho_lower_bound" in params
+    assert "base_module" in params
+
+
+def test_n_clusters(art_model):
+    # Test the n_clusters property
+    assert art_model.n_clusters == 0  # No clusters initially
+    art_model.map = {0: 0, 1: 1}
+    assert art_model.n_clusters == 2  # Two clusters
+
+
+def test_check_dimensions(art_model):
+    # Test the check_dimensions method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    art_model.check_dimensions(X)  # Should pass without assertion errors
+
+
+def test_validate_params(art_model):
+    # Test the validate_params method
+    valid_params = {"rho_lower_bound": 0.3}
+    art_model.validate_params(valid_params)
+
+    invalid_params = {"rho_lower_bound": -0.3}  # Invalid rho_lower_bound
+    with pytest.raises(AssertionError):
+        art_model.validate_params(invalid_params)
+
+
+def test_step_fit(art_model):
+    # Test the step_fit method
+    x = np.array([0.1, 0.2])
+    cluster_label = art_model.step_fit(x)
+    assert cluster_label == 0  # First sample should create a new cluster
+
+
+def test_step_pred(art_model):
+    # Test the step_pred method
+    x = np.array([0.1, 0.2])
+    art_model.step_fit(x)  # Create the first cluster
+    cluster_label = art_model.step_pred(x)
+    assert cluster_label == 0  # Predict should return the correct cluster
+
+
+def test_get_cluster_centers(art_model):
+    # Test the get_cluster_centers method
+    art_model.step_fit(np.array([0.1, 0.2]))  # Create the first cluster
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.1, 0.2]))
diff --git a/unit_tests/test_EllipsoidART.py b/unit_tests/test_EllipsoidART.py
new file mode 100644
index 0000000..f250c0e
--- /dev/null
+++ b/unit_tests/test_EllipsoidART.py
@@ -0,0 +1,142 @@
+import pytest
+import numpy as np
+from artlib.elementary.EllipsoidART import EllipsoidART
+
+
+# Fixture to initialize an EllipsoidART instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    alpha = 1e-5
+    beta = 0.1
+    mu = 0.5
+    r_hat = 1.0
+    return EllipsoidART(rho=rho, alpha=alpha, beta=beta, mu=mu, r_hat=r_hat)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert art_model.params["alpha"] == 1e-5
+    assert art_model.params["beta"] == 0.1
+    assert art_model.params["mu"] == 0.5
+    assert art_model.params["r_hat"] == 1.0
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {
+        "rho": 0.7,
+        "alpha": 1e-5,
+        "beta": 0.1,
+        "mu": 0.5,
+        "r_hat": 1.0,
+    }
+    EllipsoidART.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": 1.5,  # Invalid vigilance parameter
+        "alpha": -1e-5,  # Invalid alpha
+        "beta": 1.1,  # Invalid beta
+        "mu": -0.5,  # Invalid mu
+        "r_hat": -1.0,  # Invalid r_hat
+    }
+    with pytest.raises(AssertionError):
+        EllipsoidART.validate_params(invalid_params)
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 0.5, 0.0, 0.0]
+    )  # Mock weight (centroid, major axis, and radius)
+    params = {"rho": 0.7, "alpha": 1e-5, "mu": 0.5, "r_hat": 1.0}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "dist" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 0.5, 0.0, 0.0]
+    )  # Mock weight (centroid, major axis, and radius)
+    params = {"rho": 0.7, "r_hat": 1.0}
+    cache = {"dist": 0.6}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    expected_match_criterion = 1 - (0.0 + max(0.0, 0.6)) / 1.0
+    assert match_criterion == pytest.approx(expected_match_criterion, rel=1e-6)
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 0.5, 0.0, 0.0]
+    )  # Mock weight (centroid, major axis, and radius)
+    params = {"beta": 0.1, "mu": 0.5, "r_hat": 1.0}
+    cache = {"dist": 0.6}
+
+    updated_weight = art_model.update(i, w, params, cache=cache)
+    assert (
+        updated_weight[-1] >= w[-1]
+    )  # Ensure that the radius has not decreased
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    params = {"r_hat": 1.0}
+
+    new_weight = art_model.new_weight(i, params)
+    assert len(new_weight) == 5  # Centroid (2), major axis (2), and radius (1)
+    assert new_weight[-1] == 0.0  # Initial radius should be 0
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.dim_ = 2
+    art_model.W = [np.array([0.2, 0.3, 0.0, 0.0, 0.5])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    X = art_model.prepare_data(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.W = [np.array([0.1, 0.2, 0.0, 0.0, 0.5])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_FALCON.py b/unit_tests/test_FALCON.py
new file mode 100644
index 0000000..aa55556
--- /dev/null
+++ b/unit_tests/test_FALCON.py
@@ -0,0 +1,137 @@
+import pytest
+import numpy as np
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.reinforcement.FALCON import FALCON
+
+
+@pytest.fixture
+def falcon_model():
+    # Initialize FALCON with three FuzzyART modules
+    state_art = FuzzyART(0.5, 0.01, 1.0)
+    action_art = FuzzyART(0.7, 0.01, 1.0)
+    reward_art = FuzzyART(0.9, 0.01, 1.0)
+    channel_dims = [4, 4, 2]
+    return FALCON(
+        state_art=state_art,
+        action_art=action_art,
+        reward_art=reward_art,
+        channel_dims=channel_dims,
+    )
+
+
+def test_falcon_initialization(falcon_model):
+    # Test that the model initializes correctly
+    assert isinstance(falcon_model.fusion_art.modules[0], FuzzyART)
+    assert isinstance(falcon_model.fusion_art.modules[1], FuzzyART)
+    assert isinstance(falcon_model.fusion_art.modules[2], FuzzyART)
+    assert falcon_model.fusion_art.channel_dims == [4, 4, 2]
+
+
+def test_falcon_fit(falcon_model):
+    # Test the fit method of FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.fit(states_prep, actions_prep, rewards_prep)
+
+    assert len(falcon_model.fusion_art.W) > 0
+    assert falcon_model.fusion_art.labels_.shape[0] == states_prep.shape[0]
+
+
+def test_falcon_partial_fit(falcon_model):
+    # Test the partial_fit method of FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    assert len(falcon_model.fusion_art.W) > 0
+    assert falcon_model.fusion_art.labels_.shape[0] == states_prep.shape[0]
+
+
+def test_falcon_get_actions_and_rewards(falcon_model):
+    # Test the get_actions_and_rewards method
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.fit(states_prep, actions_prep, rewards_prep)
+    print(states_prep[0, :])
+
+    action_space, rewards = falcon_model.get_actions_and_rewards(
+        states_prep[0, :]
+    )
+
+    assert action_space.shape[0] > 0
+    assert rewards.shape[0] > 0
+
+
+def test_falcon_get_action(falcon_model):
+    # Test the get_action method of FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.fit(states_prep, actions_prep, rewards_prep)
+
+    action = falcon_model.get_action(states_prep[0, :])
+
+    assert action.shape[0] == actions.shape[1]
+
+
+def test_falcon_get_probabilistic_action(falcon_model):
+    # Test the get_probabilistic_action method of FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.fit(states_prep, actions_prep, rewards_prep)
+
+    action = falcon_model.get_probabilistic_action(states_prep[0, :])
+
+    assert isinstance(action.tolist(), float)
+
+
+def test_falcon_get_rewards(falcon_model):
+    # Test the get_rewards method of FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    falcon_model.fit(states_prep, actions_prep, rewards_prep)
+
+    predicted_rewards = falcon_model.get_rewards(states_prep, actions_prep)
+
+    assert predicted_rewards.shape == rewards.shape
diff --git a/unit_tests/test_FusionART.py b/unit_tests/test_FusionART.py
new file mode 100644
index 0000000..ea9be0d
--- /dev/null
+++ b/unit_tests/test_FusionART.py
@@ -0,0 +1,146 @@
+import pytest
+import numpy as np
+from artlib.common.BaseART import BaseART
+from artlib.fusion.FusionART import FusionART
+from artlib.elementary.FuzzyART import FuzzyART
+
+
+@pytest.fixture
+def fusionart_model():
+    # Initialize FusionART with two FuzzyART modules
+    module_a = FuzzyART(0.5, 0.01, 1.0)
+    module_b = FuzzyART(0.7, 0.01, 1.0)
+    gamma_values = np.array([0.5, 0.5])
+    channel_dims = [4, 4]
+    return FusionART(
+        modules=[module_a, module_b],
+        gamma_values=gamma_values,
+        channel_dims=channel_dims,
+    )
+
+
+def test_initialization(fusionart_model):
+    # Test that the model initializes correctly
+    assert isinstance(fusionart_model.modules[0], BaseART)
+    assert isinstance(fusionart_model.modules[1], BaseART)
+    assert np.all(
+        fusionart_model.params["gamma_values"] == np.array([0.5, 0.5])
+    )
+    assert fusionart_model.channel_dims == [4, 4]
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"gamma_values": np.array([0.5, 0.5])}
+    FusionART.validate_params(valid_params)
+
+    invalid_params = {
+        "gamma_values": np.array([0.6, 0.6])
+    }  # sum of gamma_values must be 1.0
+    with pytest.raises(AssertionError):
+        FusionART.validate_params(invalid_params)
+
+
+def test_get_cluster_centers(fusionart_model):
+    # Test the get_cluster_centers method
+    fusionart_model.modules[0].W = [np.array([0.1, 0.4, 0.5, 0.4])]
+    fusionart_model.modules[1].W = [np.array([0.2, 0.2, 0.2, 0.2])]
+    fusionart_model.modules[0].d_min_ = np.array([0.0, 0.0])
+    fusionart_model.modules[1].d_min_ = np.array([0.0, 0.0])
+    fusionart_model.modules[0].d_max_ = np.array([1.0, 1.0])
+    fusionart_model.modules[1].d_max_ = np.array([1.0, 1.0])
+
+    centers = fusionart_model.get_cluster_centers()
+
+    assert len(centers) == 1
+    assert np.allclose(centers[0], np.array([0.3, 0.5, 0.5, 0.5]))
+
+
+def test_prepare_and_restore_data(fusionart_model):
+    # Test prepare_data and restore_data methods
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+
+    X_prep = fusionart_model.prepare_data(X)
+    assert X_prep.shape == (10, 8)
+
+    X_restored = fusionart_model.restore_data(X_prep)
+    assert np.allclose(X_restored[0], X[0])
+    assert np.allclose(X_restored[1], X[1])
+
+
+def test_fit(fusionart_model):
+    # Test the fit method
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    fusionart_model.fit(X_prep, max_iter=1)
+
+    assert fusionart_model.labels_.shape[0] == X_prep.shape[0]
+
+
+def test_partial_fit(fusionart_model):
+    # Test the partial_fit method
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    fusionart_model.partial_fit(X_prep)
+
+    assert fusionart_model.labels_.shape[0] == X_prep.shape[0]
+
+
+def test_predict(fusionart_model):
+    # Test the predict method
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    fusionart_model.fit(X_prep, max_iter=1)
+
+    predictions = fusionart_model.predict(X_prep)
+    assert predictions.shape[0] == X_prep.shape[0]
+
+
+def test_step_fit(fusionart_model):
+    # Test the step_fit method with base_module's internal methods
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    # Prepare data before fitting
+    fusionart_model.modules[0].W = []
+    fusionart_model.modules[1].W = []
+
+    # Run step_fit for the first sample
+    label = fusionart_model.step_fit(X_prep[0])
+    assert isinstance(
+        label, int
+    )  # Ensure the result is an integer cluster label
+
+
+def test_step_pred(fusionart_model):
+    # Test the step_pred method
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    fusionart_model.fit(X_prep, max_iter=1)
+
+    label = fusionart_model.step_pred(X_prep[0])
+    assert isinstance(label, int)  # Ensure the result is an integer
+
+
+def test_predict_regression(fusionart_model):
+    # Test the predict_regression method
+    X = [np.random.rand(10, 2), np.random.rand(10, 2)]
+    X_prep = fusionart_model.prepare_data(X)
+
+    fusionart_model.fit(X_prep, max_iter=1)
+
+    predicted_regression = fusionart_model.predict_regression(X_prep)
+    assert predicted_regression.shape[0] == X_prep.shape[0]
+
+
+def test_join_channel_data(fusionart_model):
+    # Test the join_channel_data method
+    channel_1 = np.random.rand(10, 2)
+    channel_2 = np.random.rand(10, 2)
+
+    X = fusionart_model.join_channel_data([channel_1, channel_2])
+    assert X.shape == (10, 4)
diff --git a/unit_tests/test_FuzzyART.py b/unit_tests/test_FuzzyART.py
new file mode 100644
index 0000000..e07c848
--- /dev/null
+++ b/unit_tests/test_FuzzyART.py
@@ -0,0 +1,150 @@
+import pytest
+import numpy as np
+from unittest.mock import MagicMock
+from artlib.elementary.FuzzyART import FuzzyART
+
+# Assuming BaseART is imported and available in the current namespace
+
+
+@pytest.fixture
+def art_model():
+    # Fixture that sets up the model before each test
+    params = {
+        "rho": 0.5,
+        "alpha": 0.0,
+        "beta": 1.0,
+    }
+    return FuzzyART(**params)
+
+
+def test_initialization(art_model):
+    # Test that the ART model initializes correctly
+    assert art_model.params == {"rho": 0.5, "alpha": 0.0, "beta": 1.0}
+    assert art_model.sample_counter_ == 0
+    assert art_model.weight_sample_counter_ == []
+
+
+def test_set_get_params(art_model):
+    # Test set_params and get_params functions
+    new_params = {"rho": 0.7, "alpha": 0.05, "beta": 0.9}
+    art_model.set_params(**new_params)
+    assert art_model.get_params() == new_params
+    assert art_model.rho == 0.7
+    assert art_model.alpha == 0.05
+    assert art_model.beta == 0.9
+
+
+def test_attribute_access(art_model):
+    # Test dynamic attribute access and setting using params
+    assert art_model.rho == 0.5
+    art_model.rho = 0.8
+    assert art_model.rho == 0.8
+
+
+def test_invalid_attribute(art_model):
+    # Test accessing an invalid attribute
+    with pytest.raises(AttributeError):
+        art_model.non_existing_attribute
+
+
+def test_prepare_restore_data(art_model):
+    # Test data normalization and denormalization
+    X = np.array([[1, 2], [3, 4], [5, 6]])
+    normalized_X = art_model.prepare_data(X)
+    restored_X = art_model.restore_data(normalized_X)
+    np.testing.assert_array_almost_equal(restored_X, X)
+
+
+def test_check_dimensions(art_model):
+    # Test check_dimensions with valid data
+    X = np.array([[1, 2], [3, 4]])
+    art_model.check_dimensions(X)
+    assert art_model.dim_ == 2
+
+    # Test check_dimensions with invalid data (mismatch)
+    X_invalid = np.array([[1, 2, 3]])
+    with pytest.raises(AssertionError):
+        art_model.check_dimensions(X_invalid)
+
+
+def test_match_tracking(art_model):
+    # Test match tracking with different methods
+    cache = {"match_criterion": 0.5}
+    art_model._match_tracking(
+        cache, epsilon=0.01, params=art_model.params, method="MT+"
+    )
+    assert art_model.rho == 0.51
+
+    art_model._match_tracking(
+        cache, epsilon=0.01, params=art_model.params, method="MT-"
+    )
+    assert art_model.rho == 0.49
+
+    art_model._match_tracking(
+        cache, epsilon=0.01, params=art_model.params, method="MT0"
+    )
+    assert art_model.rho == 0.5
+
+    art_model._match_tracking(
+        cache, epsilon=0.01, params=art_model.params, method="MT~"
+    )
+    assert art_model.rho == 0.5
+
+    art_model._match_tracking(
+        cache, epsilon=0.01, params=art_model.params, method="MT1"
+    )
+    assert np.isinf(art_model.rho)
+
+
+def test_step_fit(art_model):
+    # Test step_fit for creating new clusters
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.W = []
+    art_model.new_weight = MagicMock(return_value=np.array([0.1, 0.2]))
+    art_model.add_weight = MagicMock()
+    art_model.update = MagicMock(return_value=np.array([0.15, 0.25]))
+    art_model.category_choice = MagicMock(return_value=(1.0, None))
+    art_model.match_criterion_bin = MagicMock(return_value=(True, {}))
+
+    label = art_model.step_fit(X[0])
+    assert label == 0
+    art_model.add_weight.assert_called_once()
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.new_weight = MagicMock(return_value=np.array([0.1, 0.2]))
+    art_model.add_weight = MagicMock()
+    art_model.update = MagicMock(return_value=np.array([0.15, 0.25]))
+    art_model.category_choice = MagicMock(return_value=(1.0, None))
+    art_model.match_criterion_bin = MagicMock(return_value=(True, {}))
+
+    art_model.partial_fit(X)
+    art_model.add_weight.assert_called()
+
+
+def test_predict(art_model):
+    # Test predict function
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.category_choice = MagicMock(return_value=(1.0, None))
+    art_model.step_pred = MagicMock(return_value=0)
+
+    labels = art_model.predict(X)
+    np.testing.assert_array_equal(labels, [0, 0])
+
+
+def test_clustering(art_model):
+    new_params = {"rho": 0.9, "alpha": 0.05, "beta": 1.0}
+    art_model.set_params(**new_params)
+
+    data = np.array(
+        [[0.0, 0.0], [0.0, 0.08], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]
+    )
+    data = art_model.prepare_data(data)
+    labels = art_model.fit_predict(data)
+
+    assert np.all(np.equal(labels, np.array([0, 0, 1, 2, 3])))
diff --git a/unit_tests/test_GaussianART.py b/unit_tests/test_GaussianART.py
new file mode 100644
index 0000000..2e8f011
--- /dev/null
+++ b/unit_tests/test_GaussianART.py
@@ -0,0 +1,133 @@
+import pytest
+import numpy as np
+from unittest.mock import MagicMock
+from artlib.elementary.GaussianART import GaussianART
+
+
+# Fixture to initialize a GaussianART instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    sigma_init = np.array([0.5, 0.5])
+    alpha = 1e-5
+    return GaussianART(rho=rho, sigma_init=sigma_init, alpha=alpha)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert np.array_equal(art_model.params["sigma_init"], np.array([0.5, 0.5]))
+    assert art_model.params["alpha"] == 1e-5
+    assert art_model.sample_counter_ == 0
+    assert art_model.weight_sample_counter_ == []
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {
+        "rho": 0.5,
+        "sigma_init": np.array([0.5, 0.5]),
+        "alpha": 1e-5,
+    }
+    GaussianART.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": 1.5,  # Invalid vigilance parameter
+        "sigma_init": np.array([0.5, 0.5]),
+        "alpha": -1e-5,  # Invalid alpha
+    }
+    with pytest.raises(AssertionError):
+        GaussianART.validate_params(invalid_params)
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 2.0, 2.5, 0.5, 0.6, 1.2, 1.0, 5]
+    )  # Mock weight vector
+    art_model.W = [w]
+    params = {"rho": 0.7, "alpha": 1e-5}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "exp_dist_sig_dist" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    cache = {"exp_dist_sig_dist": 0.8}
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 2.0, 2.5, 0.5, 0.6, 1.2, 1.0, 5]
+    )  # Mock weight vector
+    params = {"rho": 0.7}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    assert match_criterion == cache["exp_dist_sig_dist"]
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [0.25, 0.35, 2.0, 2.5, 0.5, 0.6, 1.2, 1.0, 5]
+    )  # Mock weight vector
+    params = {"alpha": 1e-5}
+
+    updated_weight = art_model.update(i, w, params)
+    assert (
+        updated_weight[-1] == 6
+    )  # Check that the sample count has been updated
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    i = np.array([0.2, 0.3])
+    params = {"sigma_init": np.array([0.5, 0.5])}
+
+    new_weight = art_model.new_weight(i, params)
+    assert (
+        len(new_weight) == 8
+    )  # Mean, sigma, inverse sigma, determinant, and count
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.W = [np.array([0.2, 0.3, 1.0, 1.0, 1.0, 1.0, 1.0, 5])]
+    art_model.dim_ = 2
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    X = art_model.prepare_data(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.W = [np.array([0.1, 0.2, 0.5, 0.5, 2.0, 2.0, 2.0, 1])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_HypersphereART.py b/unit_tests/test_HypersphereART.py
new file mode 100644
index 0000000..5001728
--- /dev/null
+++ b/unit_tests/test_HypersphereART.py
@@ -0,0 +1,121 @@
+import pytest
+import numpy as np
+from artlib.elementary.HypersphereART import HypersphereART
+
+
+# Fixture to initialize a HypersphereART instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    alpha = 1e-5
+    beta = 0.1
+    r_hat = 1.0
+    return HypersphereART(rho=rho, alpha=alpha, beta=beta, r_hat=r_hat)
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert art_model.params["alpha"] == 1e-5
+    assert art_model.params["beta"] == 0.1
+    assert art_model.params["r_hat"] == 1.0
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"rho": 0.7, "alpha": 1e-5, "beta": 0.1, "r_hat": 1.0}
+    HypersphereART.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": 1.5,  # Invalid vigilance parameter
+        "alpha": -1e-5,  # Invalid alpha
+        "beta": 1.1,  # Invalid beta
+        "r_hat": -1.0,  # Invalid r_hat
+    }
+    with pytest.raises(AssertionError):
+        HypersphereART.validate_params(invalid_params)
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35, 0.5])  # Mock weight (centroid and radius)
+    params = {"rho": 0.7, "alpha": 1e-5, "r_hat": 1.0}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "max_radius" in cache
+    assert "i_radius" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35, 0.5])  # Mock weight (centroid and radius)
+    params = {"rho": 0.7, "r_hat": 1.0}
+    cache = {"max_radius": 0.6}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    assert match_criterion == 1 - (max(0.5, 0.6) / 1.0)
+
+
+def test_update(art_model):
+    # Test the update method
+    i = np.array([0.2, 0.3])
+    w = np.array([0.25, 0.35, 0.5])  # Mock weight (centroid and radius)
+    params = {"beta": 0.1, "r_hat": 1.0}
+    cache = {"max_radius": 0.6, "i_radius": 0.55}
+
+    updated_weight = art_model.update(i, w, params, cache=cache)
+    assert (
+        len(updated_weight) == 3
+    )  # Check that the weight has a centroid and radius
+    assert updated_weight[-1] > 0.5  # Check that the radius has been updated
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    i = np.array([0.2, 0.3])
+    params = {"r_hat": 1.0}
+
+    new_weight = art_model.new_weight(i, params)
+    assert len(new_weight) == 3  # Centroid (2D) + radius
+    assert new_weight[-1] == 0.0  # Initial radius should be 0
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.W = [np.array([0.2, 0.3, 0.5])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    X = art_model.prepare_data(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.W = [np.array([0.1, 0.2, 0.5])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_QuadraticNeuronART.py b/unit_tests/test_QuadraticNeuronART.py
new file mode 100644
index 0000000..380e351
--- /dev/null
+++ b/unit_tests/test_QuadraticNeuronART.py
@@ -0,0 +1,158 @@
+import pytest
+import numpy as np
+from artlib.elementary.QuadraticNeuronART import QuadraticNeuronART
+
+
+# Fixture to initialize a QuadraticNeuronART instance for testing
+@pytest.fixture
+def art_model():
+    rho = 0.7
+    s_init = 0.5
+    lr_b = 0.1
+    lr_w = 0.1
+    lr_s = 0.05
+    return QuadraticNeuronART(
+        rho=rho, s_init=s_init, lr_b=lr_b, lr_w=lr_w, lr_s=lr_s
+    )
+
+
+def test_initialization(art_model):
+    # Test that the model initializes correctly
+    assert art_model.params["rho"] == 0.7
+    assert art_model.params["s_init"] == 0.5
+    assert art_model.params["lr_b"] == 0.1
+    assert art_model.params["lr_w"] == 0.1
+    assert art_model.params["lr_s"] == 0.05
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {
+        "rho": 0.7,
+        "s_init": 0.5,
+        "lr_b": 0.1,
+        "lr_w": 0.1,
+        "lr_s": 0.05,
+    }
+    QuadraticNeuronART.validate_params(valid_params)
+
+    invalid_params = {
+        "rho": 1.5,  # Invalid vigilance parameter
+        "s_init": -0.5,  # Invalid s_init
+        "lr_b": 1.1,  # Invalid learning rate for cluster mean
+        "lr_w": -0.1,  # Invalid learning rate for cluster weights
+        "lr_s": 1.5,  # Invalid learning rate for activation parameter
+    }
+    with pytest.raises(AssertionError):
+        QuadraticNeuronART.validate_params(invalid_params)
+
+
+def test_category_choice(art_model):
+    # Test the category_choice method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [1.0, 0.0, 0.0, 1.0, 0.25, 0.35, 0.5]
+    )  # Mock weight (matrix, centroid, and activation parameter)
+    params = {"rho": 0.7, "s_init": 0.5}
+
+    activation, cache = art_model.category_choice(i, w, params)
+    assert "activation" in cache
+    assert "l2norm2_z_b" in cache
+    assert isinstance(activation, float)
+
+
+def test_match_criterion(art_model):
+    # Test the match_criterion method
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [1.0, 0.0, 0.0, 1.0, 0.25, 0.35, 0.5]
+    )  # Mock weight (matrix, centroid, and activation parameter)
+    params = {"rho": 0.7}
+    cache = {"activation": 0.8}
+
+    match_criterion, new_cache = art_model.match_criterion(
+        i, w, params, cache=cache
+    )
+    assert match_criterion == cache["activation"]
+
+
+def test_update(art_model):
+    # Test the update method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    w = np.array(
+        [1.0, 0.0, 0.0, 1.0, 0.25, 0.35, 0.5]
+    )  # Mock weight (matrix, centroid, and activation parameter)
+    params = {"lr_b": 0.1, "lr_w": 0.1, "lr_s": 0.05}
+    cache = {
+        "s": 0.5,
+        "w": np.array([[1.0, 0.0], [0.0, 1.0]]),
+        "b": np.array([0.25, 0.35]),
+        "z": np.array([0.2, 0.3]),
+        "activation": 0.8,
+        "l2norm2_z_b": 0.02,
+    }
+
+    updated_weight = art_model.update(i, w, params, cache=cache)
+    assert (
+        len(updated_weight) == 7
+    )  # Check that the weight has matrix, centroid, and activation parameter
+    assert (
+        updated_weight[-1] < 0.5
+    )  # Check that the activation parameter has been updated
+
+
+def test_new_weight(art_model):
+    # Test the new_weight method
+    art_model.dim_ = 2
+    i = np.array([0.2, 0.3])
+    params = {"s_init": 0.5}
+
+    new_weight = art_model.new_weight(i, params)
+    assert (
+        len(new_weight) == 7
+    )  # Weight matrix (4 values), centroid (2 values), and activation parameter (1 value)
+    assert (
+        new_weight[-1] == 0.5
+    )  # Initial activation parameter should be s_init
+
+
+def test_get_cluster_centers(art_model):
+    # Test getting cluster centers
+    art_model.dim_ = 2
+    art_model.W = [np.array([1.0, 0.0, 0.0, 1.0, 0.2, 0.3, 0.5])]
+    centers = art_model.get_cluster_centers()
+    assert len(centers) == 1
+    assert np.array_equal(centers[0], np.array([0.2, 0.3]))
+
+
+def test_fit(art_model):
+    # Test fitting the model
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
+    X = art_model.prepare_data(X)
+    art_model.fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were created
+
+
+def test_partial_fit(art_model):
+    # Test partial_fit method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.partial_fit(X)
+
+    assert len(art_model.W) > 0  # Ensure that clusters were partially fit
+
+
+def test_predict(art_model):
+    # Test predict method
+    art_model.dim_ = 2
+    X = np.array([[0.1, 0.2], [0.3, 0.4]])
+    X = art_model.prepare_data(X)
+    art_model.W = [np.array([1.0, 0.0, 0.0, 1.0, 0.1, 0.2, 0.5])]
+
+    labels = art_model.predict(X)
+    assert len(labels) == 2
diff --git a/unit_tests/test_SMART.py b/unit_tests/test_SMART.py
new file mode 100644
index 0000000..cc70426
--- /dev/null
+++ b/unit_tests/test_SMART.py
@@ -0,0 +1,55 @@
+import pytest
+import numpy as np
+from artlib.hierarchical.SMART import SMART
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+from matplotlib.axes import Axes
+
+
+# Fixture to initialize a SMART instance for testing
+@pytest.fixture
+def smart_model():
+    base_params = {"alpha": 0.01, "beta": 1.0}
+    rho_values = [0.2, 0.5, 0.7]
+    return SMART(FuzzyART, rho_values, base_params)
+
+
+def test_initialization(smart_model):
+    # Test that the model initializes correctly
+    assert len(smart_model.rho_values) == 3
+    assert isinstance(smart_model.modules[0], BaseART)
+    assert isinstance(smart_model.modules[1], BaseART)
+    assert isinstance(smart_model.modules[2], BaseART)
+
+
+def test_prepare_and_restore_data(smart_model):
+    # Test prepare_data and restore_data methods
+    X = np.random.rand(10, 5)
+
+    X_prep = smart_model.prepare_data(X)
+
+    X_restored = smart_model.restore_data(X_prep)
+    assert np.allclose(X_restored, X)
+
+
+def test_fit(smart_model):
+    # Test the fit method
+    X = np.random.rand(10, 5)
+
+    # Prepare data before fitting
+    X_prep = smart_model.prepare_data(X)
+    smart_model.fit(X_prep, max_iter=1)
+
+    assert smart_model.modules[0].labels_.shape[0] == X.shape[0]
+
+
+def test_partial_fit(smart_model):
+    # Test the partial_fit method
+    X = np.random.rand(10, 5)
+
+    # Prepare data before partial fitting
+    X_prep = smart_model.prepare_data(X)
+    print(smart_model.n_modules)
+    smart_model.partial_fit(X_prep)
+
+    assert smart_model.modules[0].labels_.shape[0] == X.shape[0]
diff --git a/unit_tests/test_SimpleARTMAP.py b/unit_tests/test_SimpleARTMAP.py
new file mode 100644
index 0000000..c5c2544
--- /dev/null
+++ b/unit_tests/test_SimpleARTMAP.py
@@ -0,0 +1,138 @@
+import pytest
+import numpy as np
+from artlib.supervised.SimpleARTMAP import SimpleARTMAP
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.common.BaseART import BaseART
+from sklearn.utils.validation import NotFittedError
+
+
+# Fixture to initialize a SimpleARTMAP instance for testing
+@pytest.fixture
+def simple_artmap_model():
+    module_a = FuzzyART(0.5, 0.01, 1.0)
+    return SimpleARTMAP(module_a=module_a)
+
+
+def test_initialization(simple_artmap_model):
+    # Test that the model initializes correctly
+    assert isinstance(simple_artmap_model.module_a, BaseART)
+
+
+def test_get_params(simple_artmap_model):
+    # Test the get_params method
+    params = simple_artmap_model.get_params()
+    assert "module_a" in params
+
+
+def test_validate_data(simple_artmap_model):
+    # Test the validate_data method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+    X_prep = simple_artmap_model.prepare_data(X)
+
+    X_valid, y_valid = simple_artmap_model.validate_data(X_prep, y)
+    assert X_valid.shape == X_prep.shape
+    assert y_valid.shape == y.shape
+
+    # Test invalid input data
+    X_invalid = np.random.rand(0, 5)
+    with pytest.raises(ValueError):
+        simple_artmap_model.validate_data(X_invalid, y)
+
+
+def test_prepare_and_restore_data(simple_artmap_model):
+    # Test prepare_data and restore_data methods
+    X = np.random.rand(10, 5)
+
+    X_prep = simple_artmap_model.prepare_data(X)
+
+    X_restored = simple_artmap_model.restore_data(X_prep)
+    assert np.allclose(X_restored, X)
+
+
+def test_fit(simple_artmap_model):
+    # Test the fit method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.fit(X_prep, y, max_iter=1)
+
+    assert simple_artmap_model.module_a.labels_.shape[0] == X.shape[0]
+
+
+def test_partial_fit(simple_artmap_model):
+    # Test the partial_fit method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before partial fitting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.partial_fit(X_prep, y)
+
+    assert simple_artmap_model.module_a.labels_.shape[0] == X.shape[0]
+
+
+def test_predict(simple_artmap_model):
+    # Test the predict method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting and predicting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.fit(X_prep, y, max_iter=1)
+
+    predictions = simple_artmap_model.predict(X_prep)
+    assert predictions.shape[0] == X.shape[0]
+
+
+def test_predict_ab(simple_artmap_model):
+    # Test the predict_ab method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting and predicting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.fit(X_prep, y, max_iter=1)
+
+    predictions_a, predictions_b = simple_artmap_model.predict_ab(X_prep)
+    assert predictions_a.shape[0] == X.shape[0]
+    assert predictions_b.shape[0] == X.shape[0]
+
+
+def test_predict_not_fitted(simple_artmap_model):
+    # Test that predict raises an error if the model is not fitted
+    X = np.random.rand(10, 5)
+
+    with pytest.raises(NotFittedError):
+        simple_artmap_model.predict(X)
+
+
+def test_step_fit(simple_artmap_model):
+    # Test the step_fit method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.module_a.W = []
+
+    # Run step_fit for the first sample
+    c_a = simple_artmap_model.step_fit(X_prep[0], y[0])
+    assert isinstance(c_a, int)  # Ensure the result is an integer cluster label
+
+
+def test_step_pred(simple_artmap_model):
+    # Test the step_pred method
+    X = np.random.rand(10, 5)
+    y = np.random.randint(0, 2, size=10)
+
+    # Prepare data before fitting
+    X_prep = simple_artmap_model.prepare_data(X)
+    simple_artmap_model.fit(X_prep, y, max_iter=1)
+
+    c_a, c_b = simple_artmap_model.step_pred(X_prep[0])
+    print(type(c_a), type(c_b))
+    assert isinstance(c_a, (int, np.integer))
+    assert isinstance(c_b, (int, np.integer))
diff --git a/unit_tests/test_TD_FALCON.py b/unit_tests/test_TD_FALCON.py
new file mode 100644
index 0000000..0018ee5
--- /dev/null
+++ b/unit_tests/test_TD_FALCON.py
@@ -0,0 +1,168 @@
+import pytest
+import numpy as np
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.reinforcement.FALCON import TD_FALCON
+
+
+@pytest.fixture
+def td_falcon_model():
+    # Initialize TD_FALCON with three FuzzyART modules
+    state_art = FuzzyART(0.5, 0.01, 1.0)
+    action_art = FuzzyART(0.7, 0.01, 1.0)
+    reward_art = FuzzyART(0.9, 0.01, 1.0)
+    channel_dims = [4, 4, 2]
+    return TD_FALCON(
+        state_art=state_art,
+        action_art=action_art,
+        reward_art=reward_art,
+        channel_dims=channel_dims,
+    )
+
+
+def test_td_falcon_initialization(td_falcon_model):
+    # Test that the TD_FALCON model initializes correctly
+    assert isinstance(td_falcon_model.fusion_art.modules[0], FuzzyART)
+    assert isinstance(td_falcon_model.fusion_art.modules[1], FuzzyART)
+    assert isinstance(td_falcon_model.fusion_art.modules[2], FuzzyART)
+    assert td_falcon_model.fusion_art.channel_dims == [4, 4, 2]
+    assert td_falcon_model.td_alpha == 1.0
+    assert td_falcon_model.td_lambda == 1.0
+
+
+def test_td_falcon_fit_raises(td_falcon_model):
+    # Test that calling the fit method raises NotImplementedError
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    with pytest.raises(NotImplementedError):
+        td_falcon_model.fit(states, actions, rewards)
+
+
+def test_td_falcon_partial_fit(td_falcon_model):
+    # Test the partial_fit method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    td_falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    assert len(td_falcon_model.fusion_art.W) > 0
+    assert (
+        td_falcon_model.fusion_art.labels_.shape[0] == states_prep.shape[0] - 1
+    )
+
+
+def test_td_falcon_calculate_SARSA(td_falcon_model):
+    # Test the calculate_SARSA method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    # Test with multiple samples
+    (
+        states_fit,
+        actions_fit,
+        sarsa_rewards_fit,
+    ) = td_falcon_model.calculate_SARSA(states_prep, actions_prep, rewards_prep)
+
+    assert states_fit.shape == (9, 4)  # Last sample is discarded for SARSA
+    assert actions_fit.shape == (9, 4)
+    assert sarsa_rewards_fit.shape == (9, 2)
+
+    # Test with single sample
+    (
+        states_fit,
+        actions_fit,
+        sarsa_rewards_fit,
+    ) = td_falcon_model.calculate_SARSA(
+        states_prep[:1], actions_prep[:1], rewards_prep[:1]
+    )
+
+    assert states_fit.shape == (1, 4)
+    assert actions_fit.shape == (1, 4)
+    assert sarsa_rewards_fit.shape == (1, 2)
+
+
+def test_td_falcon_get_actions_and_rewards(td_falcon_model):
+    # Test the get_actions_and_rewards method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    td_falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    action_space, rewards = td_falcon_model.get_actions_and_rewards(
+        states_prep[0, :]
+    )
+
+    assert action_space.shape[0] > 0
+    assert rewards.shape[0] > 0
+
+
+def test_td_falcon_get_action(td_falcon_model):
+    # Test the get_action method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    td_falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    action = td_falcon_model.get_action(states_prep[0, :])
+
+    assert action.shape[0] == actions.shape[1]
+
+
+def test_td_falcon_get_probabilistic_action(td_falcon_model):
+    # Test the get_probabilistic_action method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    td_falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    action = td_falcon_model.get_probabilistic_action(states_prep[0, :])
+
+    assert isinstance(action.tolist(), float)
+
+
+def test_td_falcon_get_rewards(td_falcon_model):
+    # Test the get_rewards method of TD_FALCON
+    states = np.random.rand(10, 2)
+    actions = np.random.rand(10, 2)
+    rewards = np.random.rand(10, 1)
+
+    # Prepare data
+    states_prep, actions_prep, rewards_prep = td_falcon_model.prepare_data(
+        states, actions, rewards
+    )
+
+    td_falcon_model.partial_fit(states_prep, actions_prep, rewards_prep)
+
+    predicted_rewards = td_falcon_model.get_rewards(states_prep, actions_prep)
+
+    assert predicted_rewards.shape == rewards.shape
diff --git a/unit_tests/test_TopoART.py b/unit_tests/test_TopoART.py
new file mode 100644
index 0000000..3627846
--- /dev/null
+++ b/unit_tests/test_TopoART.py
@@ -0,0 +1,120 @@
+import pytest
+import numpy as np
+from artlib.topological.TopoART import TopoART
+from artlib.elementary.FuzzyART import FuzzyART
+
+
+@pytest.fixture
+def topoart_model():
+    base_module = FuzzyART(0.5, 0.01, 1.0)
+    return TopoART(base_module, beta_lower=0.5, tau=10, phi=5)
+
+
+def test_initialization(topoart_model):
+    # Test that the model initializes correctly
+    assert isinstance(topoart_model.base_module, FuzzyART)
+    assert topoart_model.params["beta_lower"] == 0.5
+    assert topoart_model.params["tau"] == 10
+    assert topoart_model.params["phi"] == 5
+
+
+def test_validate_params():
+    # Test the validate_params method
+    valid_params = {"beta": 0.8, "beta_lower": 0.5, "tau": 10, "phi": 5}
+    TopoART.validate_params(valid_params)
+
+    invalid_params = {
+        "beta": 0.4,
+        "beta_lower": 0.5,
+        "tau": 10,
+        "phi": 5,
+    }  # beta must be >= beta_lower
+    with pytest.raises(AssertionError):
+        TopoART.validate_params(invalid_params)
+
+
+def test_get_cluster_centers(topoart_model):
+    # Test the get_cluster_centers method
+    topoart_model.base_module.W = [
+        np.array([0.5, 1.0, 0.5, 1.0]),
+        np.array([0.1, 0.4, 0.5, 0.4]),
+    ]
+    topoart_model.base_module.d_min_ = np.array([0.0, 0.0])
+    topoart_model.base_module.d_max_ = np.array([1.0, 1.0])
+
+    centers = topoart_model.get_cluster_centers()
+    print(centers)
+    assert len(centers) == 2
+    assert np.allclose(centers[0], np.array([0.5, 0.5]))
+    assert np.allclose(centers[1], np.array([0.3, 0.5]))
+
+
+def test_prepare_and_restore_data(topoart_model):
+    # Test prepare_data and restore_data methods
+    X = np.random.rand(10, 2)
+
+    X_prep = topoart_model.prepare_data(X)
+
+    X_restored = topoart_model.restore_data(X_prep)
+    assert np.allclose(X_restored, X)
+
+
+def test_step_fit(topoart_model):
+    # Test the step_fit method with base_module's internal methods
+    X = np.random.rand(10, 2)
+    X_prep = topoart_model.prepare_data(X)
+    topoart_model.validate_data(X_prep)
+
+    topoart_model.base_module.W = []
+    label = topoart_model.step_fit(X_prep[0, :])
+
+    assert isinstance(
+        label, int
+    )  # Ensure the result is an integer cluster label
+    assert label == 0  # First label should be 0
+
+    # Add more data and check the adjacency matrix and labels
+    for i in range(1, 10):
+        label = topoart_model.step_fit(X_prep[i, :])
+        assert isinstance(label, int)
+
+
+def test_adjacency_matrix(topoart_model):
+    # Test that the adjacency matrix updates correctly
+    np.random.seed(42)
+    X = np.random.rand(10, 2)
+    X_prep = topoart_model.prepare_data(X)
+    topoart_model.validate_data(X_prep)
+
+    topoart_model.base_module.W = []
+    topoart_model.step_fit(X_prep[0, :])
+    assert topoart_model.adjacency.shape == (1, 1)
+
+    topoart_model.step_fit(X_prep[1, :])
+    assert topoart_model.adjacency.shape == (1, 1)
+
+    # Add more data and check the adjacency matrix
+    topoart_model.step_fit(X_prep[2, :])
+    assert topoart_model.adjacency.shape == (2, 2)
+
+
+def test_prune(topoart_model):
+    # Test the pruning mechanism
+    np.random.seed(42)
+    X = np.random.rand(10, 2)
+    topoart_model.base_module.W = [np.random.rand(2) for _ in range(5)]
+    topoart_model.weight_sample_counter_ = [
+        2,
+        6,
+        6,
+        20,
+        25,
+    ]  # Sample counter for pruning
+    topoart_model._permanent_mask = np.zeros((5,), dtype=bool)
+    topoart_model.adjacency = np.random.randint(0, 10, (5, 5))
+    topoart_model.labels_ = np.random.randint(0, 5, (10,))
+
+    topoart_model.prune(X)
+    assert (
+        len(topoart_model.W) == 4
+    )  # W should have 4 remaining weights after pruning
diff --git a/unit_tests/test_clustering_consistency.py b/unit_tests/test_clustering_consistency.py
new file mode 100644
index 0000000..10d8533
--- /dev/null
+++ b/unit_tests/test_clustering_consistency.py
@@ -0,0 +1,84 @@
+import pickle
+import pytest
+import numpy as np
+from pathlib import Path
+from artlib import (
+    ART1,
+    ART2A,
+    BayesianART,
+    DualVigilanceART,
+    EllipsoidART,
+    FuzzyART,
+    GaussianART,
+    HypersphereART,
+    QuadraticNeuronART,
+)
+
+
+# Factory function to initialize models, handling special cases like DualVigilanceART
+def model_factory(model_class, params):
+    if model_class.__name__ == "DualVigilanceART":
+        base_art = FuzzyART(params["rho"], params["alpha"], params["beta"])
+        return model_class(base_art, params["rho_lower_bound"])
+    return model_class(**params)
+
+
+# Load the clustering results from the pickle file
+@pytest.fixture(scope="module")
+def cluster_results():
+    # Define the path to the pickle file
+    current_file_path = Path(__file__).resolve().parent.parent
+    pickle_file = (
+        current_file_path / "unit_tests" / "cluster_results_snapshot.pkl"
+    )
+
+    # Load the results
+    with open(pickle_file, "rb") as f:
+        return pickle.load(f)
+
+
+# Dictionary of model classes to map model names to classes
+model_classes = {
+    "ART2A": ART2A,
+    "BayesianART": BayesianART,
+    "DualVigilanceART": DualVigilanceART,
+    "EllipsoidART": EllipsoidART,
+    "FuzzyART": FuzzyART,
+    "GaussianART": GaussianART,
+    "HypersphereART": HypersphereART,
+    "QuadraticNeuronART": QuadraticNeuronART,
+}
+
+
+@pytest.mark.parametrize("model_name", model_classes.keys())
+def test_clustering_consistency(model_name, cluster_results):
+    # Get the stored params and labels for the model
+    stored_data = cluster_results[model_name]
+    stored_params = stored_data["params"]
+    stored_labels = stored_data["labels"]
+
+    # Instantiate the model using the stored parameters
+    model_class = model_classes[model_name]
+    model_instance = model_factory(model_class, stored_params)
+
+    # Generate blob data (same data used when saving the pickle file)
+    from sklearn.datasets import make_blobs
+
+    data, _ = make_blobs(
+        n_samples=150,
+        centers=3,
+        cluster_std=0.50,
+        random_state=0,
+        shuffle=False,
+    )
+
+    # Prepare the data
+    X = model_instance.prepare_data(data)
+
+    # Fit the model and predict the clusters
+    predicted_labels = model_instance.fit_predict(X)
+
+    # Check that the predicted labels match the stored labels
+    assert np.array_equal(
+        predicted_labels, stored_labels
+    ), f"Labels for {model_name} do not match!"
diff --git a/unit_tests/test_iCVI_FuzzyART.py b/unit_tests/test_iCVI_FuzzyART.py
new file mode 100644
index 0000000..865f4e5
--- /dev/null
+++ b/unit_tests/test_iCVI_FuzzyART.py
@@ -0,0 +1,100 @@
+import pytest
+import numpy as np
+from artlib.elementary.FuzzyART import FuzzyART
+from artlib.cvi.iCVIFuzzyArt import iCVIFuzzyART
+from artlib.cvi.iCVIs.CalinkskiHarabasz import iCVI_CH
+
+
+@pytest.fixture
+def icvi_fuzzyart_model():
+    # Initialize iCVIFuzzyART with Calinski-Harabasz validity index and offline mode
+    return iCVIFuzzyART(
+        rho=0.5,
+        alpha=0.01,
+        beta=1.0,
+        validity=iCVIFuzzyART.CALINSKIHARABASZ,
+        offline=True,
+    )
+
+
+def test_icvi_fuzzyart_initialization(icvi_fuzzyart_model):
+    # Test that the model initializes correctly
+    assert isinstance(icvi_fuzzyart_model, iCVIFuzzyART)
+    assert (
+        icvi_fuzzyart_model.params["validity"] == iCVIFuzzyART.CALINSKIHARABASZ
+    )
+    assert icvi_fuzzyart_model.offline is True
+
+
+def test_icvi_fuzzyart_validate_params(icvi_fuzzyart_model):
+    # Test if validity parameter is validated correctly
+    assert "validity" in icvi_fuzzyart_model.params
+    assert isinstance(icvi_fuzzyart_model.params["validity"], int)
+    assert (
+        icvi_fuzzyart_model.params["validity"] == iCVIFuzzyART.CALINSKIHARABASZ
+    )
+
+
+def test_icvi_fuzzyart_prepare_and_restore_data(icvi_fuzzyart_model):
+    # Test prepare_data and restore_data methods
+    X = np.random.rand(10, 4)
+
+    X_prep = icvi_fuzzyart_model.prepare_data(X)
+    X_restored = icvi_fuzzyart_model.restore_data(X_prep)
+
+    assert np.allclose(X_restored, X)
+
+
+def test_icvi_fuzzyart_fit_offline(icvi_fuzzyart_model):
+    # Test the fit method in offline mode
+    X = np.random.rand(10, 4)
+    X_prep = icvi_fuzzyart_model.prepare_data(X)
+
+    icvi_fuzzyart_model.fit(X_prep)
+
+    assert len(icvi_fuzzyart_model.W) > 0
+    assert icvi_fuzzyart_model.labels_.shape[0] == X.shape[0]
+
+
+def test_icvi_fuzzyart_iCVI_match(icvi_fuzzyart_model):
+    # Test the iCVI_match method with Calinski-Harabasz index
+    X = np.random.rand(10, 4)
+    X_prep = icvi_fuzzyart_model.prepare_data(X)
+    icvi_fuzzyart_model.data = X_prep
+    icvi_fuzzyart_model.labels_ = np.random.randint(0, 2, size=(10,))
+    icvi_fuzzyart_model.iCVI = iCVI_CH(X[0])
+
+    icvi_fuzzyart_model.fit(X_prep)
+
+    x = X_prep[0]
+    w = icvi_fuzzyart_model.W[0]
+
+    # Test iCVI_match functionality
+    result = icvi_fuzzyart_model.iCVI_match(
+        x, w, 0, icvi_fuzzyart_model.params, {}
+    )
+    assert isinstance(result, np.bool_)
+
+
+def test_icvi_fuzzyart_step_fit(icvi_fuzzyart_model):
+    # Test step_fit method raises NotImplementedError from parent FuzzyART
+    X = np.random.rand(10, 4)
+    X_prep = icvi_fuzzyart_model.prepare_data(X)
+    icvi_fuzzyart_model.W = []
+
+    label = icvi_fuzzyart_model.step_fit(X_prep[0])
+    assert label == 0
+
+
+def test_icvi_fuzzyart_fit_with_custom_reset_function(icvi_fuzzyart_model):
+    # Test the fit method with a custom match_reset_func
+    X = np.random.rand(10, 4)
+    X_prep = icvi_fuzzyart_model.prepare_data(X)
+
+    def custom_reset_func(x, w, c_, params, cache):
+        return True
+
+    icvi_fuzzyart_model.fit(X_prep, match_reset_func=custom_reset_func)
+
+    assert len(icvi_fuzzyart_model.W) > 0
+    assert icvi_fuzzyart_model.labels_.shape[0] == X.shape[0]