Repository: NiklasMelton/AdaptiveResonanceLib (language: python)

Issue:
Pyproject



Discussion / hints:

Fix patch:
diff --git a/CITATIONS.txt b/CITATIONS.txt
new file mode 100644
index 0000000..f11e8ca
--- /dev/null
+++ b/CITATIONS.txt
@@ -0,0 +1,367 @@
+Amis, G. P., & Carpenter, G. A. (2007). Default ARTMAP 2. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 777–782). doi:10.1109/IJCNN.2007.4371056.
+
+Amis, G. P., & Carpenter, G. A. (2010). Self-supervised ARTMAP. Neural Networks, 23, 265 – 282. doi:10.1016/j.neunet.2009.07.026.
+
+Amorim, D. G., Delgado, M. F., Ameneiro, S. B., & Amorim, R. R. (2011). Evolu ̧c ̃ao das Redes ART e suas Funcionalidades. Revista OPARA, 1, 40 – 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.
+
+Anagnostopoulos, G. C., & Georgiopoulos, M. (2002). Category regions as new geometrical concepts in Fuzzy-ART and Fuzzy-ARTMAP. Neural Networks, 15, 1205 – 1221. doi:10.1016/S0893-6080(02)00063-1.
+
+Anagnostopoulos, G. C., & Georgiopoulos, M. (2003). Putting the Utility of Match Tracking in Fuzzy ARTMAP Training to the Test. In V. Palade, R. J. Howlett, & L. Jain (Eds.), Knowledge-Based Intelligent Information and Engineering Systems (pp. 1–6). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10. 1007/978-3-540-45226-3_1.
+
+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.
+
+Andonie, R. (1990). A Converse H-theorem for Inductive Processes. Comput. Artif. Intell., 9, 161–167. Andonie, R., & Sasu, L. (2003). A Fuzzy ARTMAP Probability Estimator with Relevance Factor. In Proc. of the 11th European Symposium on Artificial Neural Networks (ESANN) (pp. 367–372).
+
+Andonie, R., & Sasu, L. (2006). Fuzzy ARTMAP with input relevances. IEEE Transactions on Neural Networks, 17, 929–941. doi:10.1109/TNN.2006.875988. 55
+
+Andonie, R., Sasu, L., & Beiu, V. (2003). A Modified Fuzzy ARTMAP Architecture for Incremental Learning Function Approximation. In Proc. IASTED Int. Conf. Neural Networks and Computational Intelligence (NCI) (pp. 124–129).
+
+Andonie, R., Sasu, L., & Beiu, V. (2003). Fuzzy ARTMAP with relevance factor. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1975–1980). volume 3. doi:10.1109/IJCNN.2003. 1223710.
+
+Asfour, Y. R., Carpenter, G. A., Grossberg, S., & Lesher, G. W. (1993). Fusion ARTMAP: an adaptive fuzzy network for multi-channel classification. In Proc. Third International Conference on Industrial Fuzzy Control and Intelligent Systems (pp. 155–160). doi:10.1109/IFIS.1993.324195.
+
+Baek, J., Lee, H., Lee, B., Lee, H., & Kim, E. (2014). An efficient genetic selection of the presentation order in simplified fuzzy ARTMAP patterns. Applied Soft Computing, 22, 101–107. doi:10.1016/j.asoc.2014. 03.026.
+
+Bain, L. J., & Engelhardt, M. (1992). Introduction to Probability and Mathematical Statistics. (2nd ed.). Brooks/Cole, Cengage Learning.
+
+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.
+
+Bartfai, G. (1995). A comparison of two ART-based neural networks for hierarchical clustering. In Proc. Second New Zealand International Two-Stream Conference on Artificial Neural Networks and Expert Systems (pp. 83–86). doi:10.1109/ANNES.1995.499445.
+
+Bartfai, G. (1996). An ART-based modular architecture for learning hierarchical clusterings. Neurocomputing, 13, 31 – 45. doi:10.1016/0925-2312(95)00077-1.
+
+Bartfai, G., & White, R. (1997a). A fuzzy ART-based modular neuro-fuzzy architecture for learning hierarchical clusterings. In Proc. 6th International Fuzzy Systems Conference (pp. 1713–1718). volume 3. doi:10.1109/FUZZY.1997.619798.
+
+Bartfai, G., & White, R. (1997b). Adaptive Resonance Theory-based Modular Networks for Incremental Learning of Hierarchical Clusterings. Connection Science, 9, 87–112. doi:10.1080/095400997116757.
+
+Bartfai, G., & White, R. (1998). Learning and optimisation of hierarchical clusterings with ART-based modular networks. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2352–2356). volume 3. doi:10.1109/IJCNN.1998.687229.
+
+Bezdek, J. C. (2017). A Primer on Cluster Analysis: 4 Basic Methods that (usually) Work. First Edition Design Publishing.
+
+Bezdek, J. C., & Hathaway, R. J. (2002). VAT: a tool for visual assessment of (cluster) tendency. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2225–2230). volume 3. doi:10.1109/IJCNN.2002.1007487.
+
+Blume, M., & Esener, S. (1995). Optoelectronic Fuzzy ARTMAP processor. Optical Computing, 10, 213–215.
+
+Brannon, N., Conrad, G., Draelos, T., Seiffertt, J., & Wunsch II, D. C. (2006). Information Fusion and Situation Awareness using ARTMAP and Partially Observable Markov Decision Processes. In Proc. IEEE International Joint Conference on Neural Network (IJCNN) (pp. 2023–2030). doi:10.1109/IJCNN.2006. 246950.
+
+Brannon, N., Seiffertt, J., Draelos, T., & Wunsch II, D. C. (2009). Coordinated machine learning and decision support for situation awareness. Neural Networks, 22, 316 – 325. doi:10.1016/j.neunet.2009.03.013. Goal-Directed Neural Systems.
+
+Brito da Silva, L. E., Elnabarawy, I., & Wunsch II, D. C. (2018). Distributed dual vigilance fuzzy adaptive resonance theory learns online, retrieves arbitrarily-shaped clusters, and mitigates order dependence. arXiv e-prints, . arXiv:1901.00794. ArXiv:1901.00794[cs.NE].
+
+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.
+
+Brito da Silva, L. E., & Wunsch II, D. C. (2017). Validity Index-based Vigilance Test in Adaptive Resonance Theory Neural Networks. In Proc. IEEE Symposium Series on Computational Intelligence (SSCI) (pp. 1–8). doi:10.1109/SSCI.2017.8285206.
+
+Brito da Silva, L. E., & Wunsch II, D. C. (2018). A study on exploiting VAT to mitigate ordering effects in Fuzzy ART. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2351–2358). doi:10.1109/IJCNN.2018.8489724.
+
+Cacoullos, T. (1966). Estimation of a multivariate density. Annals of the Institute of Statistical Mathematics, 18, 179–189. doi:10.1007/BF02869528.
+
+Carpenter, G. A. (1994). A distributed outstar network for spatial pattern learning. Neural Networks, 7, 159 – 168. doi:10.1016/0893-6080(94)90064-7.
+
+Carpenter, G. A. (1996a). Distributed activation, search, and learning by ART and ARTMAP neural networks. In Proc. International Conference on Neural Networks (ICNN) (pp. 244–249).
+
+Carpenter, G. A. (1996b). Distributed ART networks for learning, recognition, and prediction. In Proc. World Congress on Neural Networks (WCNN) (pp. 333 – 344).
+
+Carpenter, G. A. (1997). Distributed Learning, Recognition, and Prediction by ART and ARTMAP Neural Networks. Neural Networks, 10, 1473 – 1494. doi:10.1016/S0893-6080(97)00004-X.
+
+Carpenter, G. A. (2003). Default ARTMAP. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1396–1401). volume 2. doi:10.1109/IJCNN.2003.1223900.
+
+Carpenter, G. A., & Gaddam, S. C. (2010). Biased ART: A neural architecture that shifts attention toward previously disregarded features following an incorrect prediction. Neural Networks, 23, 435 – 451. doi:10. 1016/j.neunet.2009.07.025.
+
+Carpenter, G. A., & Gjaja, M. N. (1994). Fuzzy ART Choice Functions. Proc. World Congress on Neural Networks (WCNN), (pp. 713–722).
+
+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.
+
+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. (1990). ART 3: Hierarchical search using chemical transmitters in self-organizing pattern recognition architectures. Neural Networks, 3, 129–152. doi:10.1016/0893-6080(90) 90085-Y.
+
+Carpenter, G. A., Grossberg, S., Markuzon, N., Reynolds, J. H., & Rosen, D. B. (1992). Fuzzy ARTMAP: A neural network architecture for incremental supervised learning of analog multidimensional maps. IEEE Transactions on Neural Networks, 3, 698–713. doi:10.1109/72.159059.
+
+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.
+
+Carpenter, G. A., Grossberg, S., & Reynolds, J. H. (1995). A fuzzy ARTMAP nonparametric probability estimator for nonstationary pattern recognition problems. IEEE Transactions on Neural Networks, 6, 1330–1336. doi:10.1109/72.471374.
+
+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.
+
+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.
+
+Carpenter, G. A., & Markuzon, N. (1998). ARTMAP-IC and medical diagnosis: Instance counting and inconsistent cases. Neural Networks, 11, 323 – 336. doi:10.1016/S0893-6080(97)00067-1.
+
+Carpenter, G. A., Milenova, B. L., & Noeske, B. W. (1998). Distributed ARTMAP: a neural network for fast distributed supervised learning. Neural Networks, 11, 793 – 813. doi:10.1016/S0893-6080(98)00019-7.
+
+Carpenter, G. A., & Ross, W. D. (1995). ART-EMAP: A neural network architecture for object recognition by evidence accumulation. IEEE Transactions on Neural Networks, 6, 805–818. doi:10.1109/72.392245.
+
+Carpenter, G. A., & Tan, A.-H. (1995). Rule extraction: From neural architecture to symbolic representation. Connection Science, 7, 3–27. doi:10.1080/09540099508915655.
+
+Caudell, T. P. (1992). Hybrid optoelectronic adaptive resonance theory neural processor, ART1. Appl. Opt., 31, 6220–6229. doi:10.1364/AO.31.006220.
+
+Chin, W. H., Loo, C. K., Seera, M., Kubota, N., & Toda, Y. (2016). Multi-channel Bayesian Adaptive Resonance Associate Memory for on-line topological map building. Applied Soft Computing, 38, 269 – 280. doi:10.1016/j.asoc.2015.09.031.
+
+da Silva, A. R., & Goes, L. F. W. (2018). HearthBot: An Autonomous Agent Based on Fuzzy ART Adaptive Neural Networks for the Digital Collectible Card Game HearthStone. IEEE Transactions on Games, 10, 170–181. doi:10.1109/TCIAIG.2017.2743347.
+
+Dagher, I., Georgiopoulos, M., Heileman, G. L., & Bebis, G. (1998). Ordered fuzzy ARTMAP: a fuzzy ARTMAP algorithm with a fixed order of pattern presentation. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1717–1722). volume 3. doi:10.1109/IJCNN.1998.687115.
+
+Dagher, I., Georgiopoulos, M., Heileman, G. L., & Bebis, G. (1999). An ordering algorithm for pattern presentation in fuzzy ARTMAP that tends to improve generalization performance. IEEE Transactions on Neural Networks, 10, 768–778. doi:10.1109/72.774217.
+
+DeClaris, N., & Su, M.-C. (1991). A novel class of neural networks with quadratic junctions. In Proc. IEEE International Conference on Systems, Man, and Cybernetics (pp. 1557–1562). volume 3. doi:10.1109/ ICSMC.1991.169910.
+
+DeClaris, N., & Su, M.-C. (1992). Introduction to the theory and applications of neural networks with quadratic junctions. In Proc. IEEE International Conference on Systems, Man, and Cybernetics (pp. 1320–1325). volume 2. doi:10.1109/ICSMC.1992.271603.
+
+Du, K.-L. (2010). Clustering: A neural network approach. Neural Networks, 23, 89 – 107. doi:10.1016/j. neunet.2009.08.007.
+
+Duda, R. O., Hart, P. E., & Stork, D. G. (2000). Pattern Classification. (2nd ed.). John Wiley & Sons.
+
+Eiben, A. E., & Smith, J. E. (2015). Introduction to Evolutionary Computing. (2nd ed.). Springer Publishing Company, Incorporated.
+
+Elnabarawy, I., Tauritz, D. R., & Wunsch II, D. C. (2017). Evolutionary Computation for the Automated Design of Category Functions for Fuzzy ART: An Initial Exploration. In Proc. Genetic and Evolutionary Computation Conference Companion (GECCO) GECCO’17 (pp. 1133–1140). New York, NY, USA: ACM. doi:10.1145/3067695.3082056.
+
+Elnabarawy, I., Wunsch II, D. C., & Abdelbar, A. M. (2016). Biclustering ARTMAP Collaborative Filtering Recommender System. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2986–2991). doi:10.1109/IJCNN.2016.7727578.
+
+Furao, S., & Hasegawa, O. (2006). An incremental network for on-line unsupervised classification and topology learning. Neural Networks, 19, 90 – 106. doi:10.1016/j.neunet.2005.04.006.
+
+Georgiopoulos, M., Fernlund, H., Bebis, G., & Heileman, G. L. (1996). Order of Search in Fuzzy ART and Fuzzy ARTMAP: Effect of the Choice Parameter. Neural Networks, 9, 1541 – 1559. doi:10.1016/ S0893-6080(96)00018-4.
+
+Gomez-Sanchez, E., Dimitriadis, Y. A., Cano-Izquierdo, J. M., & Lopez-Coronado, J. (2001). Safe-μARTMAP: a new solution for reducing category proliferation in fuzzy ARTMAP. In Proc. International Joint Conference on Neural Networks (IJCNN) (pp. 1197–1202). volume 2. doi:10.1109/IJCNN.2001. 939531.
+
+Gomez-Sanchez, E., Dimitriadis, Y. A., Cano-Izquierdo, J. M., & Lopez-Coronado, J. (2002). μARTMAP: use of mutual information for category reduction in Fuzzy ARTMAP. IEEE Transactions on Neural Networks, 13, 58–69. doi:10.1109/72.977271.
+
+Granger, E., Savaria, Y., Lavoie, P., & Cantin, M.-A. (1998). A comparison of self-organizing neural networks for fast clustering of radar pulses. Signal Processing, 64, 249 – 269. doi:10.1016/S0165-1684(97)00194-1.
+
+Grossberg, S. (1968). A prediction theory for some nonlinear functional-differential equations i. learning of lists. Journal of Mathematical Analysis and Applications, 21, 643 – 694. doi:10.1016/0022-247X(68) 90269-2.
+
+Grossberg, S. (1969). Some networks that can learn, remember, and reproduce any number of complicated space-time patterns, i. Journal of Mathematics and Mechanics, 19, 53–91.
+
+Grossberg, S. (1972). Neural expectation: cerebellar and retinal analogs of cells fired by learnable or unlearned pattern classes. Kybernetik, 10, 49–57. doi:10.1007/BF00288784.
+
+Grossberg, S. (1976a). Adaptive pattern classification and universal recoding: I. Parallel development and coding of neural feature detectors. Biological Cybernetics, 23, 121–134. doi:10.1007/BF00344744.
+
+Grossberg, S. (1976b). Adaptive pattern classification and universal recoding: II. Feedback, expectation, olfaction, illusions. Biological Cybernetics, 23, 187–202. doi:10.1007/BF00340335.
+
+Grossberg, S. (1980). How does a brain build a cognitive code? Psychological Review, 87, 1–51. doi:10. 1037/0033-295X.87.1.1.
+
+Grossberg, S. (2013). Adaptive Resonance Theory: how a brain learns to consciously attend, learn, and recognize a changing world. Neural networks, 37, 1–47. doi:10.1016/j.neunet.2012.09.017.
+
+Healy, M. J., & Caudell, T. P. (1998). Guaranteed two-pass convergence for supervised and inferential learning. IEEE Transactions on Neural Networks, 9, 195–204. doi:10.1109/72.655041.
+
+Healy, M. J., & Caudell, T. P. (2006). Ontologies and Worlds in Category Theory: Implications for Neural Systems. Axiomathes, 16, 165–214. doi:10.1007/s10516-005-5474-1.
+
+Healy, M. J., Caudell, T. P., & Smith, S. D. G. (1993). A neural architecture for pattern sequence verification through inferencing. IEEE Transactions on Neural Networks, 4, 9–20. doi:10.1109/72.182691.
+
+Ho, C. S., Liou, J. J., Georgiopoulos, M., Heileman, G. L., & Christodoulou, C. (1994). Analogue circuit design and implementation of an adaptive resonance theory (ART) neural network architecture. International Journal of Electronics, 76, 271–291. doi:10.1080/00207219408925926.
+
+Isawa, H., Matsushita, H., & Nishio, Y. (2008a). Fuzzy Adaptive Resonance Theory Combining Overlapped Category in consideration of connections. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 3595–3600). doi:10.1109/IJCNN.2008.4634312.
+
+Isawa, H., Matsushita, H., & Nishio, Y. (2008b). Improved Fuzzy Adaptive Resonance Theory Combining Overlapped Category in Consideration of Connections. In IEEE Workshop on Nonlinear Circuit Networks (NCN) (pp. 8–11).
+
+Isawa, H., Matsushita, H., & Nishio, Y. (2009). Fuzzy ART Combining Overlapped Categories Using Variable Vigilance Parameters. In Proc. International Workshop on Nonlinear Circuits and Signal Processing (NCSP) (pp. 661–664).
+
+Isawa, H., Tomita, M., Matsushita, H., & Nishio, Y. (2007). Fuzzy Adaptive Resonance Theory with Group Learning and its Applications. In Proc. International Symposium on Nonlinear Theory and its Applications (NOLTA) (pp. 292–295).
+
+Ishihara, S., Hatamoto, K., Nagamachi, M., & Matsubara, Y. (1993). ART1.5SSS for Kansei engineering expert system. In Proc. International Conference on Neural Networks (IJCNN) (pp. 2512–2515). volume 3. doi:10.1109/IJCNN.1993.714235.
+
+Ishihara, S., Ishihara, K., Nagamachi, M., & Matsubara, Y. (1995). arboART: ART based hierarchical clustering and its application to questionnaire data analysis. In Proc. IEEE International Conference on Neural Networks (ICNN) (pp. 532–537). volume 1. doi:10.1109/ICNN.1995.488234.
+
+Izquierdo, J. M. C., Almonacid, M., Pinzolas, M., & Ibarrola, J. (2009). dFasArt: Dynamic neural processing in FasArt model. Neural Networks, 22, 479 – 487. doi:10.1016/j.neunet.2008.09.018.
+
+Izquierdo, J. M. C., Dimitriadis, Y. A., Arau ́zo, M., & Coronado, J. L. (1996). FasArt: A New Neuro-Fuzzy Architecture for Incremental Learning in System Identification. In IFAC Proceedings Volumes (pp. 2532 – 2537). volume 29. doi:10.1016/S1474-6670(17)58055-6.
+
+Izquierdo, J. M. C., Dimitriadis, Y. A., & Coronado, J. L. (1997). FasBack: matching-error based learning for automatic generation of fuzzy logic systems. In Proc. International Fuzzy Systems Conference (pp. 1561–1566). volume 3. doi:10.1109/FUZZY.1997.619774.
+
+Izquierdo, J. M. C., Dimitriadis, Y. A., S ́anchez, E. G., & Coronado, J. L. (2001). Learning from noisy information in FasArt and FasBack neuro-fuzzy systems. Neural Networks, 14, 407 – 425. doi:10.1016/ S0893-6080(01)00031-4.
+
+Jain, L. C., Seera, M., Lim, C. P., & Balasubramaniam, P. (2014). A review of online learning in supervised neural networks. Neural Computing and Applications, 25, 491–509. doi:10.1007/s00521-013-1534-4.
+
+Kasuba, T. (1993). Simplified Fuzzy ARTMAP. AI Expert, 8, 18–25.
+
+Kennedy, J., & Eberhart, R. (1995). Particle swarm optimization. In Proc. International Conference on Neural Networks (ICNN) (pp. 1942–1948). volume 4. doi:10.1109/ICNN.1995.488968.
+
+Kim, S. (2016). Novel approaches to clustering , biclustering algorithms based on adaptive resonance theory and intelligent control. Ph.D. thesis Missouri University of Science and Technology.
+
+Kim, S., & Wunsch II, D. C. (2011). A GPU based Parallel Hierarchical Fuzzy ART clustering. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2778–2782). doi:10.1109/IJCNN. 2011.6033584.
+
+Knuth, D. E. (1964). Backus Normal Form vs. Backus Naur Form. Communications of the ACM , 7 , 735–736. doi:10.1145/355588.365140.
+
+Koltchinskii, V. (2001). Rademacher penalties and structural risk minimization. IEEE Transactions on Information Theory, 47, 1902–1914. doi:10.1109/18.930926.
+
+Lam, D., Wei, M., & Wunsch II, D. C. (2015). Clustering Data of Mixed Categorical and Numerical Type With Unsupervised Feature Learning. IEEE Access, 3, 1605–1613. doi:10.1109/ACCESS.2015.2477216.
+
+Lavoie, P. (1999). Choosing a choice function: granting new capabilities to ART. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1988–1993). volume 3. doi:10.1109/IJCNN.1999. 832689.
+
+Levine, D. S., & Penz, P. A. (1990). ART 1.5–A simplified adaptive resonance network for classifying low-dimensional analog data. In Proc. of International Conference on Neural Networks (IJCNN) (pp. 639–642). volume 2.
+
+Lim, C. P., & Harrison, R. F. (1997a). An Incremental Adaptive Network for On-line Supervised Learning and Probability Estimation. Neural Networks, 10, 925 – 939. doi:10.1016/S0893-6080(96)00123-2.
+
+Lim, C. P., & Harrison, R. F. (1997b). Modified Fuzzy ARTMAP Approaches Bayes Optimal Classification Rates: An Empirical Demonstration. Neural Networks, 10, 755 – 774. doi:10.1016/S0893-6080(96) 00112-8.
+
+Lim, C. P., & Harrison, R. F. (2000a). ART-Based Autonomous Learning Systems: Part I — Architectures and Algorithms. In L. C. Jain, B. Lazzerini, & U. Halici (Eds.), Innovations in ART Neural Networks (pp. 133–166). Heidelberg: Physica-Verlag HD. doi:10.1007/978-3-7908-1857-4_6.
+
+Lim, C. P., & Harrison, R. F. (2000b). ART-Based Autonomous Learning Systems: Part II — Applications. In L. C. Jain, B. Lazzerini, & U. Halici (Eds.), Innovations in ART Neural Networks (pp. 167–188). Heidelberg: Physica-Verlag HD. doi:10.1007/978-3-7908-1857-4_7.
+
+Lughofer, E. (2008). Extensions of vector quantization for incremental clustering. Pattern Recognition, 41, 995 – 1011. doi:10.1016/j.patcog.2007.07.019.
+
+MacQueen, J. B. (1967). Some Methods for Classification and Analysis of MultiVariate Observations. In L. M. L. Cam, & J. Neyman (Eds.), Proc. of the fifth Berkeley Symposium on Mathematical Statistics and Probability (pp. 281–297). University of California Press volume 1.
+
+Majeed, S., Gupta, A., Raj, D., & Rhee, F. C.-H. (2018). Uncertain fuzzy self-organization based clustering: interval type-2 fuzzy approach to adaptive resonance theory. Information Sciences, 424, 69 – 90. doi:10. 1016/j.ins.2017.09.062.
+
+Marriott, S., & Harrison, R. F. (1995). A modified fuzzy ARTMAP architecture for the approximation of noisy mappings. Neural Networks, 8, 619 – 641. doi:10.1016/0893-6080(94)00110-8.
+
+Martinetz, T., & Schulten, K. (1994). Topology representing networks. Neural Networks, 7, 507 – 522. doi:10.1016/0893-6080(94)90109-0.
+
+Martinetz, T. M., & Shulten, K. J. (1991). A “Neural-Gas” Network Learns Topologies. In T. Kohonen, K. M ̈akisara, O. Simula, & J. Kangas (Eds.), Artificial Neural Networks (pp. 397–402).
+
+Mart ́ınez-Zarzuela, M., D ́ıaz Pernas, F. J., D ́ıez Higuera, J. F., & Rodr ́ıguez, M. A. (2007). Fuzzy ART Neural Network Parallel Computing on the GPU. In F. Sandoval, A. Prieto, J. Cabestany, & M. Gran ̃a (Eds.), Computational and Ambient Intelligence (pp. 463–470). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/978-3-540-73007-1_57.
+
+Mart ́ınez-Zarzuela, M., D ́ıaz-Pernas, F. J., de Pablos, A. T., Perozo-Rond ́on, F., Ant ́on-Rodr ́ıguez, M., & Gonz ́alez-Ortega, D. (2011). Fuzzy ARTMAP Based Neural Networks on the GPU for High-Performance Pattern Recognition. In J. M. Ferr ́andez, J. R. A ́lvarez S ́anchez, F. de la Paz, & F. J. Toledo (Eds.), New Challenges on Bioinspired Applications (pp. 343–352). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/978-3-642-21326-7_37.
+
+Mart ́ınez-Zarzuela, M., Pernas, F. J. D., de Pablos, A. T., Rodr ́ıguez, M. A., Higuera, J. F. D., Giralda, D. B., & Ortega, D. G. (2009). Adaptative Resonance Theory Fuzzy Networks Parallel Computation Using CUDA. In J. Cabestany, F. Sandoval, A. Prieto, & J. M. Corchado (Eds.), Bio-Inspired Systems: Computational and Ambient Intelligence (pp. 149–156). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/978-3-642-02478-8_19.
+
+Massey, L. (2009). Discovery of hierarchical thematic structure in text collections with adaptive resonance theory. Neural Computing and Applications, 18, 261–273. doi:10.1007/s00521-008-0178-2.
+
+Meng, L., & Tan, A. H. (2012). Heterogeneous Learning of Visual and Textual Features for Social Web Image Co-Clustering. Technical Report School of Computer Engineering, Nanyang Technological University.
+
+Meng, L., Tan, A.-H., & Wunsch II, D. (2013). Vigilance adaptation in adaptive resonance theory. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1–7). doi:10.1109/IJCNN.2013. 6706857.
+
+Meng, L., Tan, A.-H., & Wunsch II, D. C. (2016). Adaptive scaling of cluster boundaries for large-scale social media data clustering. IEEE Transactions on Neural Networks and Learning Systems, 27, 2656– 2669. doi:10.1109/TNNLS.2015.2498625.
+
+Meng, L., Tan, A. H., & Xu, D. (2014). Semi-Supervised Heterogeneous Fusion for Multimedia Data Co-Clustering. IEEE Transactions on Knowledge and Data Engineering, 26, 2293–2306. doi:10.1109/TKDE. 2013.47.
+
+Meuth, R. J. (2009). Meta-Learning Computational Intelligence Architectures. Ph.D. thesis Missouri University of Science and Technology.
+
+Moore, B. (1989). Art 1 and pattern clustering. In Proceedings of the 1988 connectionist models summer school (pp. 174–185). Morgan Kaufmann Publishers San Mateo, CA.
+
+Nooralishahi, P., Loo, C. K., & Seera, M. (2018). Semi-supervised topo-Bayesian ARTMAP for noisy data. Applied Soft Computing, 62, 134 – 147. doi:10.1016/j.asoc.2017.10.011.
+
+Oong, T. H., & Isa, N. A. M. (2014). Feature-Based Ordering Algorithm for Data Presentation of Fuzzy ARTMAP Ensembles. IEEE Transactions on Neural Networks and Learning Systems , 25 , 812–819. doi:10. 1109/TNNLS.2013.2280579.
+
+Palaniappan, R., & Eswaran, C. (2009). Using genetic algorithm to select the presentation order of training patterns that improves simplified fuzzy ARTMAP classification performance. Applied Soft Computing, 9, 100–106. doi:10.1016/j.asoc.2008.03.003.
+
+Palmero, G. I. S., Dimitriadis, Y. A., Izquierdo, J. M. C., S ́anchez, E. G., & Hern ́andez, E. P. (2000). ART-Based Model Set for Pattern Recognition: FasArt Family. In H. Bunke, & A. Kandel (Eds.), Neuro-Fuzzy Pattern Recognition (pp. 145–175). World Scientific. doi:10.1142/9789812792204_0007.
+
+Parrado-Hern ́andez, E., G ́omez-S ́anchez, E., & Dimitriadis, Y. A. (2003). Study of distributed learning as a solution to category proliferation in Fuzzy ARTMAP based neural systems. Neural Networks, 16, 1039 – 1057. doi:10.1016/S0893-6080(03)00009-1.
+
+Parzen, E. (1962). On Estimation of a Probability Density Function and Mode. The Annals of Mathematical Statistics, 33, 1065–1076.
+
+Raijmakers, M. E., & Molenaar, P. C. (1997). Exact ART: A Complete Implementation of an ART Network. Neural Networks, 10, 649 – 669. doi:10.1016/S0893-6080(96)00111-6.
+
+RamaKrishna, K., Ramam, V. A., & Rao, R. S. (2014). Mathematical Neural Network (MaNN) Models Part III: ART and ARTMAP in OMNI METRICS. Journal of Applicable Chemistry, 3, 919 – 989.
+
+Rummery, G. A., & Niranjan, M. (1994). On-line Q-learning using connectionist systems. Technical Report CUED/F-INFENG/TR 166 Engineering Department, Cambridge University.
+
+Sanchez, E. G., Dimitriadis, Y. A., Cano-Izquierdo, J. M., & Coronado, J. L. (2000). MicroARTMAP: use of mutual information for category reduction in fuzzy ARTMAP. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 47–52). volume 6. doi:10.1109/IJCNN.2000.859371.
+
+Sasu, L. M., & Andonie, R. (2012). Function Approximation with ARTMAP Architectures. International Journal of Computers, Communications & Control, 7, 957–967. doi:10.15837/ijccc.2012.5.1355.
+
+Sasu, L. M., & Andonie, R. (2013). Bayesian ARTMAP for regression. Neural Networks, 46, 23 – 31. doi:10.1016/j.neunet.2013.04.006.
+
+Schapire, R. E. (1990). The strength of weak learnability. Machine Learning, 5, 197–227. doi:10.1007/ BF00116037.
+
+Seiffertt, J., & Wunsch II, D. C. (2010). Unified Computational Intelligence for Complex Systems volume 6 of Evolutionary Learning and Optimization. Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/ 978-3-642-03180-9.
+
+Serrano-Gotarredona, T., & Linares-Barranco, B. (1996). A Modified ART 1 Algorithm more Suitable for VLSI Implementations. Neural Networks, 9, 1025 – 1043. doi:10.1016/0893-6080(95)00145-X.
+
+Serrano-Gotarredona, T., Linares-Barranco, B., & Andreou, A. G. (1998). Adaptive Resonance Theory Microchips: Circuit Design Techniques. Norwell, MA, USA: Kluwer Academic Publishers.
+
+Simpson, P. K. (1992). Fuzzy min-max neural networks. i. classification. IEEE Transactions on Neural Networks, 3, 776–786. doi:10.1109/72.159066.
+
+Simpson, P. K. (1993). Fuzzy min-max neural networks - part 2: Clustering. IEEE Transactions on Fuzzy Systems, 1, 32–. doi:10.1109/TFUZZ.1993.390282.
+
+Smith, C., & Wunsch II, D. C. (2015). Particle Swarm Optimization in an adaptive resonance framework. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1–4). doi:10.1109/IJCNN. 2015.7280585.
+
+Specht, D. F. (1990). Probabilistic neural networks. Neural Networks, 3, 109 – 118. doi:10.1016/ 0893-6080(90)90049-Q.
+
+Specht, D. F. (1991). A general regression neural network. IEEE Transactions on Neural Networks, 2, 568–576. doi:10.1109/72.97934.
+
+Srinivasa, N. (1997). Learning and generalization of noisy mappings using a modified probart neural network. IEEE Transactions on Signal Processing, 45, 2533–2550. doi:10.1109/78.640717.
+
+Su, M.-C., DeClaris, N., & Liu, T.-K. (1997). Application of neural networks in cluster analysis. In Proc. IEEE International Conference on Systems, Man, and Cybernetics (pp. 1–6). volume 1. doi:10.1109/ ICSMC.1997.625709.
+
+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. (2002). A hierarchical approach to ART-like clustering algorithm. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 788–793). volume 1. doi:10.1109/ IJCNN.2002.1005574.
+
+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.
+
+Sutton, R. S., & Barto, A. G. (2018). Introduction to Reinforcement Learning. (2nd ed.). Cambridge, MA, USA: MIT Press.
+
+Tan, A.-H. (1995). Adaptive Resonance Associative Map. Neural Networks, 8, 437 – 446. doi:10.1016/ 0893-6080(94)00092-Z.
+
+Tan, A.-H. (1997). Cascade ARTMAP: integrating neural computation and symbolic knowledge processing. IEEE Transactions on Neural Networks, 8, 237–250. doi:10.1109/72.557661.
+
+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. (2006). Self-organizing Neural Architecture for Reinforcement Learning. In J. Wang, Z. Yi, J. M. Zurada, B.-L. Lu, & H. Yin (Eds.), Advances in Neural Networks - ISNN 2006 (pp. 470–475). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/11759966_70.
+
+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.
+
+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.
+
+Tang, X.-l., & Han, M. (2010). Semi-supervised Bayesian ARTMAP. Applied Intelligence, 33, 302–317. doi:10.1007/s10489-009-0167-x.
+
+Tou, J. T., & Gonzalez, R. C. (1974). Pattern recognition principles. Addison-Wesley,.
+
+Tsay, S. W., & Newcomb, R. W. (1991). VLSI implementation of ART1 memories. IEEE Transactions on Neural Networks, 2, 214–221. doi:10.1109/72.80330.
+
+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.
+
+Tscherepanow, M. (2011). An Extended TopoART Network for the Stable On-line Learning of Regression Functions. In B.-L. Lu, L. Zhang, & J. Kwok (Eds.), International Conference on Neural Information Processing (ICONIP) (pp. 562–571). Berlin, Heidelberg: Springer Berlin Heidelberg. doi:10.1007/978-3-642-24958-7_65.
+
+Tscherepanow, M. (2012). Incremental On-line Clustering with a Topology-Learning Hierarchical ART Neural Network Using Hyperspherical Categories. In P. Perner (Ed.), Proc. Industrial Conference on Data Mining (ICDM) (pp. 22–34). ibai-publishing.
+
+Tscherepanow, M., Kortkamp, M., & Kammer, M. (2011). A hierarchical ART network for the stable incremental learning of topological structures and associations from noisy data. Neural Networks, 24, 906 – 916. doi:10.1016/j.neunet.2011.05.009.
+
+Tscherepanow, M., Ku ̈hnel, S., & Riechers, S. (2012). Episodic Clustering of Data Streams Using a Topology-Learning Neural Network. In V. Lemaire, J.-C. Lamirel, & P. Cuxac (Eds.), Proceedings of the ECAI Workshop on Active and Incremental Learning (AIL) (pp. 24–29).
+
+Tscherepanow, M., & Riechers, S. (2012). An Incremental On-line Classifier for Imbalanced, Incomplete, and Noisy Data. In V. Lemaire, J.-C. Lamirel, & P. Cuxac (Eds.), Proceedings of the ECAI Workshop on Active and Incremental Learning (AIL) (pp. 18–23).
+
+Vakil-Baghmisheh, M.-T., & Paveˇsi ́c, N. (2003). A Fast Simplified Fuzzy ARTMAP Network. Neural Processing Letters, 17, 273–316. doi:10.1023/A:1026004816362.
+
+Versace, M., Kozma, R. T., & Wunsch, D. C. (2012). Adaptive Resonance Theory Design in Mixed Memristive-Fuzzy Hardware. In R. Kozma, R. E. Pino, & G. E. Pazienza (Eds.), Advances in Neuromorphic Memristor Science and Applications (pp. 133–153). Dordrecht: Springer Netherlands. doi:10.1007/978-94-007-4491-2_9.
+
+Verzi, S. J., Heileman, G. L., & Georgiopoulos, M. (2006). Boosted ARTMAP: Modifications to fuzzy ARTMAP motivated by boosting theory. Neural Networks, 19, 446 – 468. doi:10.1016/j.neunet.2005. 08.013.
+
+Verzi, S. J., Heileman, G. L., Georgiopoulos, M., & Anagnostopoulos, G. (2002). Off-line structural risk minimization and BARTMAP-S. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 2533–2538). volume 3. doi:10.1109/IJCNN.2002.1007542.
+
+Verzi, S. J., Heileman, G. L., Georgiopoulos, M., & Anagnostopoulos, G. C. (2003). Universal approximation with Fuzzy ART and Fuzzy ARTMAP. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1987–1992). volume 3. doi:10.1109/IJCNN.2003.1223712.
+
+Verzi, S. J., Heileman, G. L., Georgiopoulos, M., & Healy, M. J. (1998). Boosted ARTMAP. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 396–401). volume 1. doi:10.1109/ IJCNN.1998.682299.
+
+Verzi, S. J., Heileman, G. L., Georgiopoulus, M., & Healy, M. J. (2001). Rademacher penalization applied to fuzzy ARTMAP and boosted ARTMAP. In Proc. IEEE International Joint Conference on Neural Networks (IJCNN) (pp. 1191–1196). volume 2. doi:10.1109/IJCNN.2001.939530.
+
+Vigdor, B., & Lerner, B. (2007). The Bayesian ARTMAP. IEEE Transactions on Neural Networks, 18, 1628–1644. doi:10.1109/TNN.2007.900234.
+
+Wang, D., Subagdja, B., Tan, A.-H., & Ng, G.-W. (2009). Creating human-like autonomous players in real-time first person shooter computer games. In Proc. Twenty-First Innovative Applications of Artificial Intelligence Conference (pp. 173 – 178).
+
+Wang, D., & Tan, A. (2015). Creating Autonomous Adaptive Agents in a Real-Time First-Person Shooter Computer Game. IEEE Transactions on Computational Intelligence and AI in Games, 7, 123–138. doi:10. 1109/TCIAIG.2014.2336702.
+
+Watkins, C. J. C. H., & Dayan, P. (1992). Q-learning. Machine Learning, 8, 279–292. doi:10.1007/ BF00992698.
+
+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.
+
+Wunsch II, D. C. (1991). An optoelectronic learning machine: invention, experimentation, analysis of first hardware implementation of the ART 1 neural network. Ph.D. thesis University of Washington.
+
+Wunsch II, D. C. (2009). ART properties of interest in engineering applications. In Proc. International Joint Conference on Neural Networks (IJCNN) (pp. 3380–3383). doi:10.1109/IJCNN.2009.5179094.
+
+Wunsch II, D. C., Caudell, T. P., Capps, C. D., Marks, R. J., & Falk, R. A. (1993). An optoelectronic implementation of the adaptive resonance neural network. IEEE Transactions on Neural Networks, 4, 673–684. doi:10.1109/72.238321.
+
+Xu, R., & Wunsch II, D. C. (2009). Clustering. Wiley-IEEE Press.
+
+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.
+
+Yap, K. S., Lim, C. P., & Abidin, I. Z. (2008). A Hybrid ART-GRNN Online Learning Neural Network With a ε-Insensitive Loss Function. IEEE Transactions on Neural Networks, 19, 1641–1646. doi:10.1109/TNN. 2008.2000992.
+
+Yap, K. S., Lim, C. P., & Au, M. T. (2011). Improved GART Neural Network Model for Pattern Classification and Rule Extraction With Application to Power Systems. IEEE Transactions on Neural Networks, 22, 2310–2323. doi:10.1109/TNN.2011.2173502.
+
+Yap, K. S., Lim, C. P., & Mohamad-Saleh, J. (2010). An enhanced generalized adaptive resonance theory neural network and its application to medical pattern classification. Journal of Intelligent & Fuzzy Systems, 21, 65–78. doi:10.3233/IFS-2010-0436.
+
+Yava ̧s, M., & Alpaslan, F. N. (2009). Behavior categorization using Correlation Based Adaptive Resonance Theory. In Proc. 17th Mediterranean Conference on Control and Automation (pp. 724–729). doi:10.1109/ MED.2009.5164629.
+
+Yava ̧s, M., & Alpaslan, F. N. (2012). Hierarchical behavior categorization using correlation based adaptive resonance theory. Neurocomputing, 77, 71 – 81. doi:10.1016/j.neucom.2011.08.022.
+
+Zadeh, L. A. (1965). Fuzzy sets. Information and Control, 8, 338 – 353. doi:10.1016/S0019-9958(65) 90241-X.
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..65056db
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,68 @@
+# Contributing to AdaptiveResonanceLib
+
+First off, thank you for considering contributing to AdaptiveResonanceLib. It's people like you that make AdaptiveResonanceLib such a great tool.
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by the AdaptiveResonanceLib Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [niklasmelton@gmail.com].
+
+## What we are looking for
+
+AdaptiveResonanceLib is a comprehensive and modular library for Adaptive Resonance Theory (ART) algorithms. We welcome contributions in several forms, including but not limited to:
+
+- Adding new ART models
+- Enhancing existing models with new features
+- Fixing bugs and addressing issues
+- Improving documentation
+- Writing tutorials or examples
+
+## Getting started
+
+Contributions to AdaptiveResonanceLib should be made via GitHub pull requests and discussed using GitHub issues. If you're not familiar with these tools, here are some resources to get you started:
+
+- [Forking a Repository](https://help.github.com/articles/fork-a-repo/)
+- [Creating a Pull Request](https://help.github.com/articles/creating-a-pull-request/)
+- [GitHub Issues](https://guides.github.com/features/issues/)
+
+## How to contribute
+
+If you're ready to contribute, here's how you can do it:
+
+1. **Find an issue to work on**, or create a new one if you have a new idea or if you've found a bug. It helps us to assign the issue to you and to keep track of the work being done.
+
+2. **Fork the repository** and clone it locally. Create a new branch for your contributions.
+
+3. **Write or update the code**. Please make sure your code adheres to the existing style in order to keep the codebase consistent. Include comments in your code where necessary.
+
+4. **Write tests**. If you're adding new features or fixing bugs, please add tests that cover your changes.
+
+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. 
+
+7. **Submit a pull request**. Include a clear description of the changes and reference any related issues. 
+
+## Pull Request Process
+
+1. Ensure any install or build dependencies are removed before the end of the layer when doing a build.
+2. Update the README.md with details of changes to the interface, this includes new environment variables, exposed ports, useful file locations, and container parameters.
+3. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
+4. The pull request will be merged once approved by the maintainers.
+
+## Reporting Bugs
+
+Bugs are tracked as GitHub issues. When you are creating a bug report, please include as much detail as possible about the problem. This helps contributors and maintainers to understand the issue and find potential fixes.
+
+## Feature Requests
+
+Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's maintainers of the merits of this feature. Please provide as much detail and context as possible.
+
+## Code review process
+
+The core team looks at Pull Requests on a regular basis. After feedback has been given, we expect responses within two weeks. After two weeks we may close the pull request if it isn't showing any activity.
+
+## Community
+
+Keep an eye on the GitHub issues for discussions regarding proposed features, a bug, or contributions in general. This is a great way to connect with the community and contribute to a project you like.
+
+Thank you for your interest in contributing to AdaptiveResonanceLib!
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8619a05
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) [2024] [Niklas M. Melton]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 0466373..de6fa49 100644
--- a/README.md
+++ b/README.md
@@ -1,1 +1,89 @@
-"# AdaptiveResonanceLib" 
+
+# AdaptiveResonanceLib
+
+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.
+
+## Available Models
+
+AdaptiveResonanceLib includes implementations for the following ART models:
+
+- ART1
+- ART2
+- Bayesian ART
+- Gaussian ART
+- Hypersphere ART
+- Ellipsoidal ART
+- Fuzzy ART
+- Quadratic Neuron ART
+- Dual Vigilance ART
+- Topo ART
+- Simple ARTMAP
+- ARTMAP
+- DeepARTMAP
+- SMART
+- Fusion ART
+- Biclustering ARTMAP
+
+## Installation
+
+To install AdaptiveResonanceLib, simply use pip:
+
+[comment]: <> (```bash)
+
+[comment]: <> (pip install AdaptiveResonanceLib)
+
+[comment]: <> (```)
+
+```bash
+pip install artlib
+```
+
+Ensure you have Python 3.9 or newer installed.
+
+## Quick Start
+
+Here's a quick example of how to use AdaptiveResonanceLib with the Fuzzy ART model:
+
+```python
+from adaptiveresonancelib import FuzzyART
+import numpy as np
+
+# Your dataset
+train_X = np.array([...])
+test_X = np.array([...])
+
+# Initialize the Fuzzy ART model
+model = FuzzyART(rho=0.7, alpha = 0.0, beta=1.0)
+
+# Fit the model
+model.fit(train_X)
+
+# Predict new data points
+predictions = model.predict(test_X)
+```
+
+Replace `params` with the parameters appropriate for your use case.
+
+## Documentation
+
+For more detailed documentation, including the full list of parameters for each model, visit our [documentation page](https://github.com/NiklasMelton/AdaptiveResonanceLib).
+
+## Examples
+
+For examples of how to use each model in AdaptiveResonanceLib, check out the `/examples` directory in our repository.
+
+## 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/45FjtRCt) and participate directly in the discussion.
+
+## License
+
+AdaptiveResonanceLib is open source and available under the MIT license. See the `LICENSE` file for more info.
+
+## Contact
+
+For questions and support, please open an issue in the GitHub issue tracker or message us on our [Discord server](https://discord.gg/45FjtRCt). We'll do our best to assist you.
+
+Happy Modeling with AdaptiveResonanceLib!
diff --git a/artlib/__init__.py b/artlib/__init__.py
new file mode 100644
index 0000000..6fb4b5b
--- /dev/null
+++ b/artlib/__init__.py
@@ -0,0 +1,47 @@
+from artlib.common.BaseART import BaseART
+from artlib.common.BaseARTMAP import BaseARTMAP
+from artlib.common.utils import normalize, compliment_code
+
+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
+from artlib.elementary.HypersphereART import HypersphereART
+from artlib.elementary.QuadraticNeuronART import QuadraticNeuronART
+
+from artlib.supervised.ARTMAP import ARTMAP, SimpleARTMAP
+
+from artlib.hierarchical.SMART import SMART
+from artlib.hierarchical.DeepARTMAP import DeepARTMAP
+
+from artlib.fusion.FusionART import FusionART
+
+from artlib.biclustering.BARTMAP import BARTMAP
+
+from artlib.topological.TopoART import TopoART
+
+__all__ = [
+    "BaseART",
+    "BaseARTMAP",
+    "normalize",
+    "compliment_code",
+    "ART1",
+    "ART2A",
+    "BayesianART",
+    "GaussianART",
+    "EllipsoidART",
+    "HypersphereART",
+    "QuadraticNeuronART",
+    "FuzzyART",
+    "TopoART",
+    "DualVigilanceART",
+    "ARTMAP",
+    "SimpleARTMAP",
+    "DeepARTMAP",
+    "SMART",
+    "FusionART",
+    "BARTMAP",
+]
\ No newline at end of file
diff --git a/artlib/biclustering/BARTMAP.py b/artlib/biclustering/BARTMAP.py
new file mode 100644
index 0000000..2b39fda
--- /dev/null
+++ b/artlib/biclustering/BARTMAP.py
@@ -0,0 +1,344 @@
+"""
+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
+from collections import defaultdict
+from matplotlib.colors import Colormap
+from artlib.common.BaseART import BaseART
+from sklearn.base import BaseEstimator, BiclusterMixin
+from scipy.stats import pearsonr
+
+class BARTMAP(BaseEstimator, BiclusterMixin):
+    rows_: np.ndarray #bool
+    columns_: np.ndarray #bool
+
+    def __init__(self, module_a: BaseART, module_b: BaseART, eta: float):
+        """
+
+        Parameters:
+        - module_a: a-side ART module
+        - module_b: b-side ART module
+        - eta: minimum correlation
+
+        """
+        params: dict = {"eta": eta}
+        self.validate_params(params)
+        self.params = params
+        self.module_a = module_a
+        self.module_b = module_b
+
+    def __getattr__(self, key):
+        if key in self.params:
+            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}'")
+
+    def __setattr__(self, key, value):
+        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:
+        """
+
+        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 = self.params
+
+        deep_a_items = self.module_a.get_params().items()
+        out.update(("module_a" + "__" + k, val) for k, val in deep_a_items)
+        out["module_a"] = self.module_a
+
+        deep_b_items = self.module_b.get_params().items()
+        out.update(("module_b" + "__" + k, val) for k, val in deep_b_items)
+        out["module_b"] = self.module_b
+        return out
+
+    def set_params(self, **params):
+        """Set the parameters of this estimator.
+
+        Specific redefinition of sklearn.BaseEstimator.set_params for ART classes
+
+        Parameters:
+        - **params : Estimator parameters.
+
+        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()
+
+        nested_params = defaultdict(dict)  # grouped by prefix
+        for key, value in params.items():
+            key, delim, sub_key = key.partition("__")
+            if key not in valid_params:
+                local_valid_params = list(valid_params.keys())
+                raise ValueError(
+                    f"Invalid parameter {key!r} for estimator {self}. "
+                    f"Valid parameters are: {local_valid_params!r}."
+                )
+
+            if delim:
+                nested_params[key][sub_key] = value
+            else:
+                setattr(self, key, value)
+                valid_params[key] = value
+                local_params[key] = value
+
+        for key, sub_params in nested_params.items():
+            valid_params[key].set_params(**sub_params)
+        self.validate_params(local_params)
+        return self
+
+    @staticmethod
+    def validate_params(params):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        assert "eta" in params
+        assert isinstance(params["eta"], float)
+
+    @property
+    def column_labels_(self):
+        return self.module_b.labels_
+
+    @property
+    def row_labels_(self):
+        return self.module_a.labels_
+
+    @property
+    def n_row_clusters(self):
+        return self.module_a.n_clusters
+
+    @property
+    def n_column_clusters(self):
+        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
+
+        Parameters:
+        - x: a sample vector
+        - c_b: b-side cluster label
+
+        Returns:
+            x filtered to 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
+
+        Parameters:
+        - a: some vector
+        - b: some vector
+
+        Returns:
+            Pearson correlation
+
+        """
+        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
+
+        """
+        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)
+        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
+            ]
+        )
+
+        return float(mean_r)
+
+    def validate_data(self, X_a: np.ndarray, X_b: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        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
+
+        """
+        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
+    ) -> 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
+
+        """
+        k = extra["k"]
+        for cluster_b in range(len(self.module_b.W)):
+            if self.match_criterion_bin(self.X, k, cluster_b, params):
+                return True
+        return False
+
+    def step_fit(self, X: np.ndarray, k: int) -> int:
+        """
+        fit the model to a single sample
+
+        Parameters:
+        - X: data set
+        - k: sample index
+
+        Returns:
+            cluster label of the input sample
+
+        """
+        match_reset_func = lambda i, w, cluster, params, cache: self.match_reset_func(
+            i, w, cluster, params=params, extra={"k": k}, cache=cache
+        )
+        c_a = self.module_a.step_fit(X[k, :], match_reset_func=match_reset_func)
+        return c_a
+
+    def fit(self, X: np.ndarray, max_iter=1):
+        """
+        Fit the model to the data
+
+        Parameters:
+        - X: data set
+        - max_iter: number of iterations to fit the model on the same data set
+
+        """
+        # Check that X and y have correct shape
+        self.X = X
+
+        n = X.shape[0]
+        X_a = self.module_b.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
+        self.module_a.W = []
+        self.module_a.labels_ = np.zeros((X.shape[0],), dtype=int)
+
+        for _ in range(max_iter):
+            for k in range(n):
+                print(k, self.module_a.n_clusters)
+                self.module_a.pre_step_fit(X)
+                c_a = self.step_fit(X_a, k)
+                self.module_a.labels_[k] = c_a
+                self.module_a.post_step_fit(X)
+
+        self.rows_ = np.vstack(
+            [
+                self.row_labels_ == label
+                for label in range(self.module_a.n_clusters)
+                for _ in range(self.module_b.n_clusters)
+            ]
+        )
+        self.columns_ = np.vstack(
+            [
+                self.column_labels_ == label
+                for _ in range(self.module_a.n_clusters)
+                for label in range(self.module_b.n_clusters)
+            ]
+        )
+        return self
+
+
+    def visualize(
+            self,
+            cmap: Optional[Colormap] = None
+    ):
+        """
+        Visualize the clustering of the data
+
+        Parameters:
+        - cmap: some colormap
+
+        """
+        import matplotlib.pyplot as plt
+
+        if cmap is None:
+            from matplotlib.pyplot import cm
+            cmap=plt.cm.Blues
+
+        plt.matshow(
+            np.outer(np.sort(self.row_labels_) + 1, np.sort(self.column_labels_) + 1),
+            cmap=cmap,
+        )
diff --git a/artlib/biclustering/__init__.py b/artlib/biclustering/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/common/BaseART.py b/artlib/common/BaseART.py
new file mode 100644
index 0000000..4d8aa03
--- /dev/null
+++ b/artlib/common/BaseART.py
@@ -0,0 +1,473 @@
+import numpy as np
+from typing import Optional, Callable, Iterable
+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
+
+
+class BaseART(BaseEstimator, ClusterMixin):
+    # Generic implementation of Adaptive Resonance Theory (ART)
+    def __init__(self, params: dict):
+        """
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        self.validate_params(params)
+        self.params = params
+        self.sample_counter_ = 0
+        self.weight_sample_counter_: list[int] = []
+
+    def __getattr__(self, key):
+        if key in self.params:
+            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}'")
+
+    def __setattr__(self, key, value):
+        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:
+        """
+
+        Parameters:
+        - deep: If True, will return the parameters for this class and contained subobjects that are estimators.
+
+        Returns:
+            Parameter names mapped to their values.
+
+        """
+        return self.params
+
+    def set_params(self, **params):
+        """Set the parameters of this estimator.
+
+        Specific redefinition of sklearn.BaseEstimator.set_params for ART classes
+
+        Parameters:
+        - **params : Estimator parameters.
+
+        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()
+
+        nested_params = defaultdict(dict)  # grouped by prefix
+        for key, value in params.items():
+            key, delim, sub_key = key.partition("__")
+            if key not in valid_params:
+                local_valid_params = list(valid_params.keys())
+                raise ValueError(
+                    f"Invalid parameter {key!r} for estimator {self}. "
+                    f"Valid parameters are: {local_valid_params!r}."
+                )
+
+            if delim:
+                nested_params[key][sub_key] = value
+            else:
+                setattr(self, key, value)
+                valid_params[key] = value
+                local_params[key] = value
+
+        for key, sub_params in nested_params.items():
+            valid_params[key].set_params(**sub_params)
+        self.validate_params(local_params)
+        return self
+
+
+    @staticmethod
+    def prepare_data(X: np.ndarray) -> np.ndarray:
+        """
+        prepare data for clustering
+
+        Parameters:
+        - X: data set
+
+        Returns:
+            normalized data
+        """
+        return normalize(X)
+
+    @property
+    def n_clusters(self) -> int:
+        """
+        get the current number of clusters
+
+        Returns:
+            the number of clusters
+        """
+        if hasattr(self, "W"):
+            return len(self.W)
+        else:
+            return 0
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        raise NotImplementedError
+
+    def check_dimensions(self, X: np.ndarray):
+        """
+        check the data has the correct dimensions
+
+        Parameters:
+        - X: data set
+
+        """
+        if not hasattr(self, "dim_"):
+            self.dim_ = X.shape[1]
+        else:
+            assert X.shape[1] == self.dim_
+
+    def validate_data(self, X: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set
+
+        """
+        assert np.all(X >= 0), "Data has not been normalized"
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            cluster activation, 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
+
+        """
+        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
+
+        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
+
+        """
+        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
+
+        Returns:
+            updated cluster weight, cache used for later processing
+
+        """
+        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
+
+        Returns:
+            updated cluster weight
+
+        """
+        raise NotImplementedError
+
+    def add_weight(self, new_w: np.ndarray):
+        """
+        add a new cluster weight
+
+        Parameters:
+        - new_w: 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
+
+        Parameters:
+        - idx: index of cluster to update
+        - new_w: new cluster weight
+
+        """
+        self.weight_sample_counter_[idx] += 1
+        self.W[idx] = new_w
+
+    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None) -> 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
+
+        """
+        self.sample_counter_ += 1
+        if len(self.W) == 0:
+            w_new = self.new_weight(x, self.params)
+            self.add_weight(w_new)
+            return 0
+        else:
+            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(T > 0):
+                c_ = int(np.argmax(T))
+                w = self.W[c_]
+                cache = T_cache[c_]
+                m, cache = self.match_criterion_bin(x, w, 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))
+                    return c_
+                else:
+                    T[c_] = -1
+
+            c_new = len(self.W)
+            w_new = self.new_weight(x, self.params)
+            self.add_weight(w_new)
+            return 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.W) >= 0, "ART module is not fit."
+
+        T, _ = zip(*[self.category_choice(x, w, params=self.params) for w in self.W])
+        c_ = int(np.argmax(T))
+        return c_
+
+    def pre_step_fit(self, X: np.ndarray):
+        """
+        undefined function called prior to each sample fit. Useful for cluster pruning
+
+        Parameters:
+        - X: data set
+
+        """
+        # 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
+
+        Parameters:
+        - X: data set
+
+        """
+        # this is where pruning steps can go
+        pass
+
+
+    def fit(self, X: np.ndarray, match_reset_func: Optional[Callable] = None, max_iter=1):
+        """
+        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
+        - max_iter: number of iterations to fit the model on the same data set
+
+        """
+        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)
+        for _ in range(max_iter):
+            for i, x in enumerate(X):
+                self.pre_step_fit(X)
+                c = self.step_fit(x, match_reset_func=match_reset_func)
+                self.labels_[i] = c
+                self.post_step_fit(X)
+        return self
+
+
+    def partial_fit(self, X: np.ndarray, match_reset_func: Optional[Callable] = None):
+        """
+        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
+
+        """
+
+        self.validate_data(X)
+        self.check_dimensions(X)
+        self.is_fitted_ =  True
+
+        if not hasattr(self, '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')
+        for i, x in enumerate(X):
+            c = self.step_fit(x, match_reset_func=match_reset_func)
+            self.labels_[i+j] = c
+        return self
+
+
+    def predict(self, X: np.ndarray) -> np.ndarray:
+        """
+        predict labels for the data
+
+        Parameters:
+        - X: data set
+
+        Returns:
+            labels for the data
+
+        """
+
+        check_is_fitted(self)
+        self.validate_data(X)
+        self.check_dimensions(X)
+
+        y = np.zeros((X.shape[0],), dtype=int)
+        for i, x in enumerate(X):
+            c = self.step_pred(x)
+            y[i] = c
+        return y
+
+    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
+
+        """
+        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
+    ):
+        """
+        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
+
+        """
+        import matplotlib.pyplot as plt
+
+        if ax is None:
+            fig, ax = plt.subplots()
+
+        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)
+
+        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
new file mode 100644
index 0000000..e92a52f
--- /dev/null
+++ b/artlib/common/BaseARTMAP.py
@@ -0,0 +1,160 @@
+import numpy as np
+from typing import Union, Optional, Iterable
+from collections import defaultdict
+from matplotlib.axes import Axes
+from sklearn.base import BaseEstimator, ClassifierMixin, ClusterMixin
+
+class BaseARTMAP(BaseEstimator, ClassifierMixin, ClusterMixin):
+
+    def __init__(self):
+        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
+
+        Parameters:
+        - **params : Estimator parameters.
+
+        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()
+
+        nested_params = defaultdict(dict)  # grouped by prefix
+        for key, value in params.items():
+            key, delim, sub_key = key.partition("__")
+            if key not in valid_params:
+                local_valid_params = list(valid_params.keys())
+                raise ValueError(
+                    f"Invalid parameter {key!r} for estimator {self}. "
+                    f"Valid parameters are: {local_valid_params!r}."
+                )
+
+            if delim:
+                nested_params[key][sub_key] = value
+            else:
+                setattr(self, key, value)
+                valid_params[key] = value
+                local_params[key] = value
+
+        for key, sub_params in nested_params.items():
+            valid_params[key].set_params(**sub_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
+
+        Parameters:
+        - y_a: side a label(s)
+
+        Returns:
+            side B cluster label(s)
+
+        """
+        if isinstance(y_a, int):
+            return self.map[y_a]
+        u, inv = np.unique(y_a, return_inverse=True)
+        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
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        raise NotImplementedError
+
+    def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1):
+        """
+        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
+
+        """
+        raise NotImplementedError
+
+    def partial_fit(self, X: np.ndarray, y: np.ndarray):
+        """
+        Partial fit the model to the data
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        raise NotImplementedError
+
+    def predict(self, X: np.ndarray) -> np.ndarray:
+        """
+        predict labels for the data
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            B 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
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            A labels for the data, B 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
+
+        Parameters:
+        - ax: figure axes
+        - colors: colors to use for each cluster
+        - linewidth: width of boundary line
+
+        """
+        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
+    ):
+        """
+        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
+
+        """
+        raise NotImplementedError
\ No newline at end of file
diff --git a/artlib/common/__init__.py b/artlib/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/common/utils.py b/artlib/common/utils.py
new file mode 100644
index 0000000..106e029
--- /dev/null
+++ b/artlib/common/utils.py
@@ -0,0 +1,65 @@
+import numpy as np
+
+def normalize(data: np.ndarray) -> np.ndarray:
+    """
+    normalize data betweeon 0 and 1
+
+    Parameters:
+    - data: data set
+
+    Returns:
+        normalized data
+    """
+    normalized = (data-np.min(data))/(np.max(data)-np.min(data))
+    return normalized
+
+def compliment_code(data: np.ndarray) -> np.ndarray:
+    """
+    compliment code data
+
+    Parameters:
+    - data: data set
+
+    Returns:
+        compliment coded data
+    """
+    cc_data = np.hstack([data, 1.0-data])
+    return cc_data
+
+def l1norm(x: np.ndarray) -> float:
+    """
+    get l1 norm of a vector
+
+    Parameters:
+    - x: some vector
+
+    Returns:
+        l1 norm
+    """
+    return float(np.sum(np.absolute(x)))
+
+def l2norm2(data: np.ndarray) -> float:
+    """
+    get (l2 norm)^2 of a vector
+
+    Parameters:
+    - x: some vector
+
+    Returns:
+        (l2 norm)^2
+    """
+    return float(np.matmul(data, data))
+
+def fuzzy_and(x: np.ndarray, y: np.ndarray) -> np.ndarray:
+    """
+    get the fuzzy AND operation between two vectors
+
+    Parameters:
+    - a: some vector
+    - b: some vector
+
+    Returns:
+        Fuzzy AND result
+
+    """
+    return np.minimum(x, y)
diff --git a/artlib/common/visualization.py b/artlib/common/visualization.py
new file mode 100644
index 0000000..7214a9d
--- /dev/null
+++ b/artlib/common/visualization.py
@@ -0,0 +1,131 @@
+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
+):
+    """
+    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
+
+    """
+    from matplotlib.patches import Ellipse
+
+    # Calculate the number of steps
+    steps = int(max_std / sigma_steps)
+    alphas = np.linspace(1, 0.1, steps)
+
+    if len(color) != 4:
+        color = np.concatenate([color, [1.]])
+
+    for i, alpha in zip(range(1, steps + 1), alphas):
+        # Adjust the alpha value of the color
+        current_color = np.copy(color)
+        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}σ')
+        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
+):
+    """
+    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
+
+    """
+    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)
+    angle = np.arctan2(
+        *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)
+    alphas = np.linspace(1, 0.1, steps)
+
+    for i, alpha in zip(range(1, steps + 1), alphas):
+        # Adjust the alpha value of the color
+        current_color = np.copy(color)
+        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}σ')
+        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
+):
+    """
+    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
+    """
+    # Compute the transformation matrix
+    transform_matrix = W[:2, :2]
+
+    # Generate points on a unit circle
+    theta = np.linspace(0, 2 * np.pi, 100)
+    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)
+
+    # Shift the ellipse to the specified mean
+    ellipse[0, :] += mean[0]
+    ellipse[1, :] += mean[1]
+
+    # Plotting
+    ax.plot(ellipse[0, :], ellipse[1, :], color=color, linewidth=linewidth)
diff --git a/artlib/elementary/ART1.py b/artlib/elementary/ART1.py
new file mode 100644
index 0000000..79660fd
--- /dev/null
+++ b/artlib/elementary/ART1.py
@@ -0,0 +1,150 @@
+"""
+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
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import normalize, l1norm
+
+def prepare_data(data: np.ndarray) -> np.ndarray:
+    normalized = normalize(data)
+    return normalized
+
+
+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
+
+        """
+        params = {
+            "rho": rho,
+            "beta": beta,
+            "L": L
+        }
+        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 "beta" in params
+        assert "L" in params
+        assert 1. >= params["rho"] >= 0.
+        assert 1. >= params["beta"] >= 0.
+        assert params["L"] >= 1.
+        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
+
+        Parameters:
+        - X: data set
+
+        """
+        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_]
+        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_:]
+        return l1norm(np.logical_and(i, w_td)) / l1norm(i), cache
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params, cache)
+        return M >= params["rho"], 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_:]
+
+        w_td_new = np.logical_and(i, w_td)
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        w_td_new = i
+        w_bu_new = (params["L"] / (params["L"] - 1 + self.dim_))*w_td_new
+        return np.concatenate([w_bu_new, w_td_new])
diff --git a/artlib/elementary/ART2.py b/artlib/elementary/ART2.py
new file mode 100644
index 0000000..a14dcef
--- /dev/null
+++ b/artlib/elementary/ART2.py
@@ -0,0 +1,165 @@
+"""
+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.
+"""
+
+"""
+==================================================================
+DISCLAIMER: DO NOT USE ART2!!!
+IT DOES NOT WORK
+It is provided for completeness only.
+Stephan Grossberg himself has said ART2 does not work.
+==================================================================
+"""
+
+import numpy as np
+from typing import Optional
+from warnings import warn
+from artlib.common.BaseART import BaseART
+
+
+class ART2A(BaseART):
+    warn("Do Not Use ART2. It does not work. This module is provided for completeness only")
+    # implementation of ART 2-A
+    def __init__(self, rho: float, alpha: float, beta: float):
+        """
+        Parameters:
+        - rho: vigilance parameter
+        - alpha: choice parameter
+        - beta: learning rate
+
+        """
+        params = {
+            "rho": rho,
+            "alpha": alpha,
+            "beta": beta,
+        }
+        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 "alpha" in params
+        assert "beta" in params
+        assert 1. >= params["rho"] >= 0.
+        assert 1. >= params["alpha"] >= 0.
+        assert 1. >= params["beta"] >= 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
+
+        Parameters:
+        - X: data set
+
+        """
+        if not hasattr(self, "dim_"):
+            self.dim_ = X.shape[1]
+            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
+
+        Returns:
+            cluster activation, 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
+
+        """
+        if cache is None:
+            raise ValueError("No cache provided")
+        # TODO: make this more efficient
+        M = cache["activation"]
+        M_u = params["alpha"]*np.sum(i)
+        # suppress if uncommitted activation is higher
+        if M < M_u:
+            return -1., cache
+        else:
+            return M, cache
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params, cache)
+        return M >= params["rho"], 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 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
+
+        Returns:
+            updated cluster weight
+
+        """
+        return i
diff --git a/artlib/elementary/BayesianART.py b/artlib/elementary/BayesianART.py
new file mode 100644
index 0000000..a498c0b
--- /dev/null
+++ b/artlib/elementary/BayesianART.py
@@ -0,0 +1,202 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import normalize
+from artlib.common.visualization import plot_gaussian_contours_covariance
+
+def prepare_data(data: np.ndarray) -> np.ndarray:
+    normalized = normalize(data)
+    return normalized
+
+
+class BayesianART(BaseART):
+    # implementation of Bayesian ART
+    pi2 = np.pi * 2
+    def __init__(self, rho: float, cov_init: np.ndarray):
+        """
+        Parameters:
+        - rho: vigilance parameter
+        - cov_init: initial estimate of covariance matrix
+
+        """
+        params = {
+            "rho": rho,
+            "cov_init": cov_init,
+        }
+        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 "cov_init" in params
+        assert params["rho"] > 0
+        assert isinstance(params["rho"], float)
+        assert isinstance(params["cov_init"], np.ndarray)
+
+    def check_dimensions(self, X: np.ndarray):
+        """
+        check the data has the correct dimensions
+
+        Parameters:
+        - X: data set
+
+        """
+        if not hasattr(self, "dim_"):
+            self.dim_ = X.shape[1]
+            assert self.params["cov_init"].shape[0] == self.dim_
+            assert self.params["cov_init"].shape[1] == 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
+
+        Returns:
+            cluster activation, cache used for later processing
+
+        """
+        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)))
+        det_cov = np.linalg.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
+        }
+
+        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
+
+        """
+        # 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
+        new_w = self.update(i, w, params, cache)
+        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) -> 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
+
+        """
+        M, cache = self.match_criterion(i, w, params=params, cache=cache)
+        return M <= params["rho"], 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
+
+        """
+        if cache is None:
+            raise ValueError("No cache provided")
+
+        if "new_w" in cache:
+            return cache["new_w"]
+
+        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
+
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        return np.concatenate([i, params["cov_init"].flatten(), [1]])
+
+
+    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 w, col in zip(self.W, colors):
+            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
new file mode 100644
index 0000000..ba52d52
--- /dev/null
+++ b/artlib/elementary/DualVigilanceART.py
@@ -0,0 +1,216 @@
+"""
+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
+from warnings import warn
+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, 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__}"
+            )
+        params = dict(base_module.params, **{"rho_lower_bound": lower_bound})
+        super().__init__(params)
+        self.base_module = base_module
+        self.lower_bound = lower_bound
+        self.map: dict[int, int] = dict()
+
+    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 = self.params
+        if deep:
+            deep_items = self.base_module.get_params().items()
+            out.update(("base_module" + "__" + k, val) for k, val in deep_items)
+            out["base_module"] = self.base_module
+        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)
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        assert "rho" in params, \
+            "Dual Vigilance ART is only compatible with ART modules relying on 'rho' for vigilance."
+        assert "rho_lower_bound" in params, \
+            "Dual Vigilance ART requires a lower bound 'rho' value"
+        assert params["rho"] > params["rho_lower_bound"] >= 0
+        assert isinstance(params["rho"], float)
+        assert isinstance(params["rho_lower_bound"], float)
+
+    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None) -> 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
+
+        """
+        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.argmax(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)
+                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.params, cache=cache)
+                        self.base_module.set_weight(c_, new_w)
+                        return self.map[c_]
+                    else:
+                        lb_params = dict(self.base_module.params, **{"rho": self.lower_bound})
+                        m2, _ = self.base_module.match_criterion_bin(x, w, params=lb_params, cache=cache)
+                        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_]
+                            return self.map[c_new]
+                T[c_] = -1
+
+            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
+            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 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
+
+        """
+        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
new file mode 100644
index 0000000..ecce7ed
--- /dev/null
+++ b/artlib/elementary/EllipsoidART.py
@@ -0,0 +1,228 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import l2norm2
+
+class EllipsoidART(BaseART):
+    # implementation of EllipsoidART
+    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
+
+        """
+        params = {
+            "rho": rho,
+            "alpha": alpha,
+            "beta": beta,
+            "mu": mu,
+            "r_hat": r_hat,
+        }
+        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 "alpha" in params
+        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 isinstance(params["rho"], float)
+        assert isinstance(params["alpha"], float)
+        assert isinstance(params["beta"], float)
+        assert isinstance(params["mu"], float)
+        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)
+
+        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)
+            )
+        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
+
+        """
+        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
+
+        """
+        radius = w[-1]
+        if cache is None:
+            raise ValueError("No cache provided")
+        dist = cache["dist"]
+
+        return 1 - (radius + max(radius, dist))/params["r_hat"], cache
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params=params, cache=cache)
+        return M >= params["rho"], 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
+
+        """
+        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))
+
+        if not radius == 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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        return np.concatenate([i, np.zeros_like(i), [0.]])
+
+    def get_2d_ellipsoids(self) -> list[tuple]:
+        ellipsoids = []
+        for w in self.W:
+            centroid = w[:2]
+            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
+
+            ellipsoids.append((centroid, width, height, angle))
+
+        return ellipsoids
+
+    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
+
+        """
+        from matplotlib.patches import Ellipse
+
+        ellipsoids = self.get_2d_ellipsoids()
+        for (centroid, width, height, angle), col in zip(ellipsoids, colors):
+            ellip = Ellipse(
+                centroid,
+                width,
+                height,
+                angle=angle,
+                linewidth=linewidth,
+                edgecolor=col,
+                facecolor='none'
+            )
+            ax.add_patch(ellip)
+
+
+
+
+
+
diff --git a/artlib/elementary/FuzzyART.py b/artlib/elementary/FuzzyART.py
new file mode 100644
index 0000000..860f619
--- /dev/null
+++ b/artlib/elementary/FuzzyART.py
@@ -0,0 +1,227 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import normalize, compliment_code, l1norm, fuzzy_and
+
+
+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
+
+    Returns:
+        reference_point, lengths of each edge
+    """
+    n_ = int(len(w) / 2)
+    if n is None:
+        n = n_
+    assert n <= n_, f"Requested bbox dimension {n_} exceed data dimension {n_}"
+
+    ref_point = []
+    widths = []
+
+    for i in range(n):
+        a_min = w[i]
+        a_max = 1-w[i+n]
+
+        ref_point.append(a_min)
+        widths.append(a_max-a_min)
+
+    return ref_point, widths
+
+
+class FuzzyART(BaseART):
+    # implementation of FuzzyART
+    def __init__(self, rho: float, alpha: float, beta: float):
+        """
+        Parameters:
+        - rho: vigilance parameter
+        - alpha: choice parameter
+        - beta: learning rate
+
+        """
+        params = {
+            "rho": rho,
+            "alpha": alpha,
+            "beta": beta,
+        }
+        super().__init__(params)
+
+    @staticmethod
+    def prepare_data(X: np.ndarray) -> np.ndarray:
+        """
+        prepare data for clustering
+
+        Parameters:
+        - X: data set
+
+        Returns:
+            normalized and compliment coded data
+        """
+        normalized = normalize(X)
+        cc_data = compliment_code(normalized)
+        return cc_data
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict 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 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
+
+        Parameters:
+        - X: data set
+
+        """
+        if not hasattr(self, "dim_"):
+            self.dim_ = X.shape[1]
+            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
+
+        Parameters:
+        - X: data set
+
+        """
+        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"
+        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
+
+        """
+        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
+
+        """
+        return l1norm(fuzzy_and(i, w)) / self.dim_original, cache
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params)
+        return M >= params["rho"], 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
+
+        """
+        b = params["beta"]
+        if b is None:
+            return fuzzy_and(i, w)
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        return i
+
+    def get_bounding_boxes(self, n: Optional[int] = None):
+        return list(map(lambda w: get_bounding_box(w, n=n), self.W))
+
+    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
+
+        """
+        from matplotlib.patches import Rectangle
+        bboxes = self.get_bounding_boxes(n=2)
+        for bbox, col in zip(bboxes, colors):
+            rect = Rectangle(
+                (bbox[0][0], bbox[0][1]),
+                bbox[1][0],
+                bbox[1][1],
+                linewidth=linewidth,
+                edgecolor=col,
+                facecolor='none'
+            )
+            ax.add_patch(rect)
diff --git a/artlib/elementary/GaussianART.py b/artlib/elementary/GaussianART.py
new file mode 100644
index 0000000..8dd7fa1
--- /dev/null
+++ b/artlib/elementary/GaussianART.py
@@ -0,0 +1,168 @@
+"""
+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 typing import Optional, Iterable
+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
+    pi2 = np.pi*2
+    def __init__(self, rho: float, sigma_init: np.ndarray):
+        """
+        Parameters:
+        - rho: vigilance parameter
+        - sigma_init: initial estimate of the diagonal std
+
+        """
+        params = {
+            "rho": rho,
+            "sigma_init": sigma_init,
+        }
+        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 "sigma_init" in params
+        assert 1.0 >= params["rho"] > 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_]
+        sigma = w[self.dim_:-1]
+        n = w[-1]
+        sig = np.diag(np.multiply(sigma,sigma))
+        dist = mean-i
+        exp_dist_sig_dist = np.exp(-0.5*np.matmul(dist.T, np.matmul(np.linalg.inv(sig), dist)))
+        cache = {
+            "exp_dist_sig_dist": exp_dist_sig_dist
+        }
+        p_i_cj = exp_dist_sig_dist/np.sqrt((self.pi2**self.dim_)*np.linalg.det(sig))
+        p_cj = n/np.sum(w_[-1] for w_ in self.W)
+
+        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
+
+        """
+        if cache is None:
+            raise ValueError("No cache provided")
+        exp_dist_sig_dist = cache["exp_dist_sig_dist"]
+        return exp_dist_sig_dist, cache
+
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params=params, cache=cache)
+        return M >= params["rho"], 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_:-1]
+        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))
+
+        return np.concatenate([mean_new, sigma_new, [n_new]])
+
+
+    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
+
+        Returns:
+            updated cluster weight
+
+        """
+        return np.concatenate([i, params["sigma_init"], [1.]])
+
+
+    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 w, col in zip(self.W, colors):
+            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
new file mode 100644
index 0000000..30e95de
--- /dev/null
+++ b/artlib/elementary/HypersphereART.py
@@ -0,0 +1,195 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import l2norm2
+
+class HypersphereART(BaseART):
+    # implementation of HypersphereART
+    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
+
+        """
+        params = {
+            "rho": rho,
+            "alpha": alpha,
+            "beta": beta,
+            "r_hat": r_hat,
+        }
+        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 "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 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_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
+
+        """
+        centroid = w[:-1]
+        radius = w[-1]
+
+        i_radius = self.category_distance(i, centroid, radius, params)
+        max_radius = max(radius, i_radius)
+
+        cache = {
+            "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
+
+        """
+        radius = w[-1]
+        if cache is None:
+            raise ValueError("No cache provided")
+        max_radius = cache["max_radius"]
+
+        return 1 - (max(radius, max_radius)/params["r_hat"]), cache
+
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params=params, cache=cache)
+        return M >= params["rho"], 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
+
+        """
+        centroid = w[:-1]
+        radius = w[-1]
+        if cache is None:
+            raise ValueError("No cache provided")
+        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))
+
+        return np.concatenate([centroid_new, [radius_new]])
+
+
+    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
+
+        Returns:
+            updated cluster weight
+
+        """
+        return np.concatenate([i, [0.]])
+
+
+    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
+
+        """
+        from matplotlib.patches import Circle
+
+        for w, col in zip(self.W, colors):
+            centroid = (w[0], w[1])
+            radius = w[-1]
+            circ = Circle(
+                centroid,
+                radius,
+                linewidth=linewidth,
+                edgecolor=col,
+                facecolor='none'
+            )
+            ax.add_patch(circ)
+
+
+
+
+
+
diff --git a/artlib/elementary/QuadraticNeuronART.py b/artlib/elementary/QuadraticNeuronART.py
new file mode 100644
index 0000000..c99a1ee
--- /dev/null
+++ b/artlib/elementary/QuadraticNeuronART.py
@@ -0,0 +1,197 @@
+"""
+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.
+"""
+
+import numpy as np
+from typing import Optional, Iterable
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.utils import normalize, l2norm2
+from artlib.common.visualization import plot_weight_matrix_as_ellipse
+
+def prepare_data(data: np.ndarray) -> np.ndarray:
+    normalized = normalize(data)
+    return normalized
+
+
+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
+
+        """
+        params = {
+            "rho": rho,
+            "s_init": s_init,
+            "lr_b": lr_b,
+            "lr_w": lr_w,
+            "lr_s": lr_s,
+        }
+        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 "s_init" in params
+        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 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
+
+        """
+        dim2 = self.dim_ * self.dim_
+        w_ = w[:dim2].reshape((self.dim_, self.dim_))
+        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)
+
+        cache = {
+            "activation": activation,
+            "l2norm2_z_b": l2norm2_z_b,
+            "w": w_,
+            "b": b,
+            "s": s,
+            "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
+
+        """
+        if cache is None:
+            raise ValueError("No cache provided")
+        return cache["activation"], cache
+
+    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
+
+        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
+
+        """
+        M, cache = self.match_criterion(i, w, params, cache)
+        return M >= params["rho"], 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
+
+        """
+        s = cache["s"]
+        w_ = cache["w"]
+        b = cache["b"]
+        z = cache["z"]
+        T = cache["activation"]
+        l2norm2_z_b = cache["l2norm2_z_b"]
+
+        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)
+
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        w_new = np.identity(self.dim_)
+        return np.concatenate([w_new.flatten(), i, [params["s_init"]]])
+
+    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
+
+        """
+        # kinda works
+        for w, col in zip(self.W, colors):
+            dim2 = self.dim_ * self.dim_
+            w_ = w[:dim2].reshape((self.dim_, self.dim_))
+            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
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/fusion/FusionART.py b/artlib/fusion/FusionART.py
new file mode 100644
index 0000000..ebb3678
--- /dev/null
+++ b/artlib/fusion/FusionART.py
@@ -0,0 +1,262 @@
+"""
+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
+from artlib.common.BaseART import BaseART
+
+def get_channel_position_tuples(channel_dims: list[int]) -> list[tuple[int, int]]:
+    positions = []
+    start = 0
+    for length in channel_dims:
+        end = start + length
+        positions.append((start, end))
+        start = end
+    return positions
+
+class FusionART(BaseART):
+    # implementation of FusionART
+
+    def __init__(
+            self,
+            modules: list[BaseART],
+            gamma_values: Union[list[float], np.ndarray],
+            channel_dims: Union[list[int], np.ndarray]
+    ):
+        assert len(modules) == len(gamma_values) == len(channel_dims)
+        params = {"gamma_values": gamma_values}
+        super().__init__(params)
+        self.modules = modules
+        self.n = len(self.modules)
+        self.channel_dims = channel_dims
+        self._channel_indices = get_channel_position_tuples(self.channel_dims)
+        self.dim_ = sum(channel_dims)
+
+    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 = self.params
+        for i, module in enumerate(self.modules):
+            deep_items = module.get_params().items()
+            out.update((f"module_{i}" + "__" + k, val) for k, val in deep_items)
+            out[f"module_{i}"] = module
+        return out
+
+
+    @property
+    def W(self):
+        W = [
+            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):
+        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]]
+            else:
+                self.modules[k].W = []
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        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)
+
+
+    def validate_data(self, X: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set
+
+        """
+        self.check_dimensions(X)
+        for k in range(self.n):
+            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
+
+        Parameters:
+        - X: data set
+
+        """
+        assert X.shape[1] == self.dim_, "Invalid data shape"
+
+    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
+
+        """
+        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
+                )
+                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)])
+        return activation, cache
+
+    def match_criterion(self, i: np.ndarray, w: np.ndarray, params: dict, cache: Optional[dict] = None) -> tuple[list[float], dict]:
+        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]],
+                    self.modules[k].params,
+                    cache[k]
+                )
+                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) -> 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
+
+        """
+        if cache is None:
+            raise ValueError("No cache provided")
+        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]],
+                    self.modules[k].params,
+                    cache[k]
+                )
+                for k in range(self.n)
+            ]
+        )
+        cache = {k: cache_k for k, cache_k in enumerate(caches)}
+        return all(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
+
+        """
+        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]],
+                self.modules[k].params,
+                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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+        W = [
+            self.modules[k].new_weight(
+                i[self._channel_indices[k][0]:self._channel_indices[k][1]],
+                self.modules[k].params,
+            )
+            for k in range(self.n)
+        ]
+        return np.concatenate(W)
+
+    def add_weight(self, new_w: np.ndarray):
+        """
+        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]]
+            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
+
+        Parameters:
+        - idx: index of cluster to update
+        - new_w: new cluster weight
+
+        """
+        for k in range(self.n):
+            new_w_k = new_w[self._channel_indices[k][0]:self._channel_indices[k][1]]
+            self.modules[k].set_weight(idx, new_w_k)
diff --git a/artlib/fusion/__init__.py b/artlib/fusion/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/hierarchical/DeepARTMAP.py b/artlib/hierarchical/DeepARTMAP.py
new file mode 100644
index 0000000..38afc6c
--- /dev/null
+++ b/artlib/hierarchical/DeepARTMAP.py
@@ -0,0 +1,235 @@
+"""
+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
+from collections import defaultdict
+from artlib.common.BaseART import BaseART
+from artlib.common.BaseARTMAP import BaseARTMAP
+from artlib.supervised.SimpleARTMAP import SimpleARTMAP
+from artlib.supervised.ARTMAP import ARTMAP
+
+class DeepARTMAP(BaseEstimator, ClassifierMixin, ClusterMixin):
+
+    def __init__(self, modules: list[BaseART]):
+        """
+
+        Parameters:
+        - modules: list of ART modules
+
+        """
+        assert len(modules) >= 1, "Must provide at least one ART module"
+        self.modules = modules
+        self.layers: list[BaseARTMAP]
+        self.is_supervised: Optional[bool] = None
+
+    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 = dict()
+        for i, module in enumerate(self.modules):
+            out[f"module_{i}"] = module
+            if deep:
+                deep_items = module.get_params().items()
+                out.update((f"module_{i}" + "__" + k, val) for k, val in deep_items)
+        return out
+
+    def set_params(self, **params):
+        """Set the parameters of this estimator.
+
+        Specific redefinition of sklearn.BaseEstimator.set_params for ARTMAP classes
+
+        Parameters:
+        - **params : Estimator parameters.
+
+        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()
+
+        nested_params = defaultdict(dict)  # grouped by prefix
+        for key, value in params.items():
+            key, delim, sub_key = key.partition("__")
+            if key not in valid_params:
+                local_valid_params = list(valid_params.keys())
+                raise ValueError(
+                    f"Invalid parameter {key!r} for estimator {self}. "
+                    f"Valid parameters are: {local_valid_params!r}."
+                )
+
+            if delim:
+                nested_params[key][sub_key] = value
+            else:
+                setattr(self, key, value)
+                valid_params[key] = value
+                local_params[key] = value
+
+        for key, sub_params in nested_params.items():
+            valid_params[key].set_params(**sub_params)
+        return self
+
+    @property
+    def labels_(self):
+        return self.layers[0].labels_
+
+    @property
+    def labels_deep_(self):
+        return np.concatenate([layer.labels_ for layer in self.layers]+[self.layers[-1].labels_a])
+
+    @property
+    def n_modules(self):
+        return len(self.modules)
+
+    @property
+    def n_layers(self):
+        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)
+
+        Returns:
+            cluster label(s) at highest level
+
+        """
+        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)
+        else:
+            return y_b
+
+
+    def validate_data(
+            self,
+            X: list[np.ndarray],
+            y: Optional[np.ndarray] = None
+    ):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: list of deep data sets
+        - y: optional labels for data
+
+        """
+        assert len(X) == self.n_modules, \
+            f"Must provide {self.n_modules} input matrices for {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 fit(self, X: list[np.ndarray], y: Optional[np.ndarray] = None, max_iter=1):
+        """
+        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
+
+        """
+        X, y = 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)
+        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)
+
+        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)
+
+        return self
+
+
+    def partial_fit(self, X: list[np.ndarray], y: Optional[np.ndarray] = None):
+        """
+        Partial fit the model to the data
+
+        Parameters:
+        - X: list of deep datasets
+        - y: optional labels
+
+        """
+        X, y = 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)
+            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])
+            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)
+            x_i += 1
+        return self
+
+
+    def predict(self, X: Union[np.ndarray, list[np.ndarray]]) -> list[np.ndarray]:
+        """
+        predict labels for the data
+
+        Parameters:
+        - X: list of deep data sets
+
+        Returns:
+            B labels for the data
+
+        """
+        if isinstance(X, list):
+            x = X[-1]
+        else:
+            x = X
+        pred_a, pred_b = self.layers[-1].predict_ab(x)
+        pred = [pred_a, pred_b]
+        for layer in self.layers[:-1][::-1]:
+            pred.append(layer.map_a2b(pred[-1]))
+
+        return pred[::-1]
+
+
+
+
+
diff --git a/artlib/hierarchical/SMART.py b/artlib/hierarchical/SMART.py
new file mode 100644
index 0000000..29777d4
--- /dev/null
+++ b/artlib/hierarchical/SMART.py
@@ -0,0 +1,106 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.hierarchical.DeepARTMAP import DeepARTMAP
+
+class SMART(DeepARTMAP):
+
+    def __init__(self, base_ART_class: Type, rho_values: Union[list[float], np.ndarray], base_params: dict, **kwargs):
+        """
+
+        Parameters:
+        - base_ART: some ART class
+        - rho_values: rho parameters for each sub-module
+        - base_params: base param dict for each sub-module
+
+        """
+
+        assert all(np.diff(rho_values) > 0), "rho_values must be monotonically increasing"
+        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)
+
+    def fit(self, X: np.ndarray, y: Optional[np.ndarray] = None, max_iter=1):
+        """
+        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
+
+        """
+        X_list = [X]*self.n_modules
+        return super().fit(X_list, max_iter=max_iter)
+
+    def partial_fit(self, X: np.ndarray, y: Optional[np.ndarray] = None):
+        X_list = [X] * self.n_modules
+        return self.partial_fit(X_list)
+
+    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 j in range(len(self.modules)):
+            layer_colors = []
+            for k in range(self.modules[j].n_clusters):
+                if j == 0:
+                    layer_colors.append(colors[k])
+                else:
+                    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
+    ):
+        """
+        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
+
+        """
+        import matplotlib.pyplot as plt
+
+        if ax is None:
+            fig, ax = plt.subplots()
+
+        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)
+
+        self.plot_cluster_bounds(ax, colors, linewidth)
\ No newline at end of file
diff --git a/artlib/hierarchical/__init__.py b/artlib/hierarchical/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/supervised/ARTMAP.py b/artlib/supervised/ARTMAP.py
new file mode 100644
index 0000000..ca2c743
--- /dev/null
+++ b/artlib/supervised/ARTMAP.py
@@ -0,0 +1,135 @@
+"""
+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 artlib.common.BaseART import BaseART
+from artlib.supervised.SimpleARTMAP import SimpleARTMAP
+from sklearn.utils.validation import check_is_fitted
+
+
+class ARTMAP(SimpleARTMAP):
+    def __init__(self, module_a: BaseART, module_b: BaseART):
+        """
+
+        Parameters:
+        - module_a: a-side ART module
+        - module_b: b-side ART module
+
+        """
+        self.module_b = module_b
+        super(ARTMAP, self).__init__(module_a)
+
+    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 = {
+            "module_a": self.module_a,
+            "module_b": self.module_b,
+        }
+
+        if deep:
+            deep_a_items = self.module_a.get_params().items()
+            out.update(("module_a" + "__" + k, val) for k, val in deep_a_items)
+
+            deep_b_items = self.module_b.get_params().items()
+            out.update(("module_b" + "__" + k, val) for k, val in deep_b_items)
+        return out
+
+
+    @property
+    def labels_a(self):
+        return self.module_a.labels_
+
+    @property
+    def labels_b(self):
+        return self.labels_
+
+    @property
+    def labels_ab(self):
+        return {"A": self.labels_a, "B": self.labels_}
+
+    def validate_data(self, X: np.ndarray, y: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        self.module_a.validate_data(X)
+        self.module_b.validate_data(y)
+
+    def fit(self, X: np.ndarray, y: np.ndarray, max_iter=1):
+        """
+        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
+
+        """
+        # Check that X and y have correct shape
+        self.validate_data(X, y)
+
+        self.module_b.fit(y, max_iter=max_iter)
+
+        y_c = self.module_b.labels_
+
+        super(ARTMAP, self).fit(X, y_c, max_iter=max_iter)
+
+        return self
+
+
+    def partial_fit(self, X: np.ndarray, y: np.ndarray):
+        """
+        Partial fit the model to the data
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        self.validate_data(X, y)
+        self.module_b.partial_fit(y)
+        super(ARTMAP, self).partial_fit(X, self.labels_b)
+        return self
+
+
+    def predict(self, X: np.ndarray) -> np.ndarray:
+        """
+        predict labels for the data
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            B labels for the data
+
+        """
+        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
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            A labels for the data, B labels for the data
+
+        """
+        check_is_fitted(self)
+        return super(ARTMAP, self).predict_ab(X)
diff --git a/artlib/supervised/SimpleARTMAP.py b/artlib/supervised/SimpleARTMAP.py
new file mode 100644
index 0000000..70d89a0
--- /dev/null
+++ b/artlib/supervised/SimpleARTMAP.py
@@ -0,0 +1,282 @@
+"""
+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
+from matplotlib.axes import Axes
+from artlib.common.BaseART import BaseART
+from artlib.common.BaseARTMAP import BaseARTMAP
+from sklearn.utils.validation import check_is_fitted, check_X_y
+from sklearn.utils.multiclass import unique_labels
+
+
+class SimpleARTMAP(BaseARTMAP):
+
+    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: 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
+
+        """
+        cluster_b = extra["cluster_b"]
+        if cluster_a in self.map and self.map[cluster_a] != cluster_b:
+            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:
+        """
+
+        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 = {"module_a": self.module_a}
+        if deep:
+            deep_items = self.module_a.get_params().items()
+            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]:
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        X, y = check_X_y(X, y)
+        self.module_a.validate_data(X)
+        return X, y
+
+    def step_fit(self, x: np.ndarray, c_b: int) -> int:
+        """
+        Fit the model to a single sample
+
+        Parameters:
+        - x: data sample for side A
+        - c_b: side b label
+
+        Returns:
+            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
+        )
+        c_a = self.module_a.step_fit(x, match_reset_func=match_reset_func)
+        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):
+        """
+        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
+
+        """
+        # Check that X and y have correct shape
+        SimpleARTMAP.validate_data(self, X, y)
+        # Store the classes seen during fit
+        self.classes_ = unique_labels(y)
+        self.labels_ = y
+        # init module A
+        self.module_a.W = []
+        self.module_a.labels_ = np.zeros((X.shape[0],), dtype=int)
+
+        for _ in range(max_iter):
+            for i, (x, c_b) in enumerate(zip(X, y)):
+                c_a = self.step_fit(x, c_b)
+                self.module_a.pre_step_fit(X)
+                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):
+        """
+        Partial fit the model to the data
+
+        Parameters:
+        - X: data set A
+        - y: data set B
+
+        """
+        SimpleARTMAP.validate_data(self, X, y)
+        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_[j:] = y
+            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)
+            self.module_a.labels_[i+j] = c_a
+            self.module_a.post_step_fit(X)
+        return self
+
+    @property
+    def labels_a(self):
+        return self.module_a.labels_
+
+    @property
+    def labels_b(self):
+        return self.labels_
+
+    @property
+    def labels_ab(self):
+        return {"A": self.labels_a, "B": self.labels_}
+
+    @property
+    def n_clusters(self):
+        return self.module_a.n_clusters
+
+    @property
+    def n_clusters_a(self):
+        return self.n_clusters
+
+    @property
+    def n_clusters_b(self):
+        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
+
+        Parameters:
+        - x: data sample for side A
+
+        Returns:
+            side A cluster label, side B cluster label
+
+        """
+        c_a = self.module_a.step_pred(x)
+        c_b = self.map[c_a]
+        return c_a, c_b
+
+    def predict(self, X: np.ndarray) -> np.ndarray:
+        """
+        predict labels for the data
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            B labels for the data
+
+        """
+        check_is_fitted(self)
+        y_b = np.zeros((X.shape[0],), dtype=int)
+        for i, x in enumerate(X):
+            c_a, c_b = self.step_pred(x)
+            y_b[i] = c_b
+        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
+
+        Parameters:
+        - X: data set A
+
+        Returns:
+            A labels for the data, B labels for the data
+
+        """
+        check_is_fitted(self)
+        y_a = np.zeros((X.shape[0],), dtype=int)
+        y_b = np.zeros((X.shape[0],), dtype=int)
+        for i, x in enumerate(X):
+            c_a, c_b = self.step_pred(x)
+            y_a[i] = c_a
+            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
+
+        Parameters:
+        - ax: figure axes
+        - colors: colors to use for each cluster
+        - linewidth: width of boundary line
+
+        """
+        colors_a = []
+        for k_a in range(self.n_clusters):
+            colors_a.append(colors[self.map[k_a]])
+        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
+    ):
+        """
+        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
+
+        """
+        import matplotlib.pyplot as plt
+
+        if ax is None:
+            fig, ax = plt.subplots()
+
+        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)
+
+        self.plot_cluster_bounds(ax, colors, linewidth)
diff --git a/artlib/supervised/__init__.py b/artlib/supervised/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/artlib/topological/TopoART.py b/artlib/topological/TopoART.py
new file mode 100644
index 0000000..c89042e
--- /dev/null
+++ b/artlib/topological/TopoART.py
@@ -0,0 +1,282 @@
+"""
+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
+from matplotlib.axes import Axes
+from warnings import warn
+from artlib.common.BaseART import BaseART
+
+
+class TopoART(BaseART):
+
+    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__}"
+            )
+        params = dict(base_module.params, **{"beta_lower": betta_lower, "tau": tau, "phi": phi})
+        super().__init__(params)
+        self.base_module = base_module
+        self.adjacency = np.zeros([], dtype=int)
+        self._permanent_mask = np.zeros([], dtype=bool)
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        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
+        assert params["beta"] >= params["beta_lower"]
+        assert params["phi"] <= params["tau"]
+        assert isinstance(params["beta"], float)
+        assert isinstance(params["beta_lower"], float)
+        assert isinstance(params["tau"], int)
+        assert isinstance(params["phi"], int)
+
+    @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 validate_data(self, X: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set
+
+        """
+        self.base_module.validate_data(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
+
+        """
+        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
+
+        """
+        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) -> 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 self.base_module.match_criterion_bin(i, w, params, 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
+
+        """
+        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
+
+        Parameters:
+        - i: data sample
+        - w: cluster weight / info
+        - params: dict containing parameters for the algorithm
+
+        Returns:
+            updated cluster weight
+
+        """
+
+        return self.base_module.new_weight(i, params)
+
+
+    def add_weight(self, new_w: np.ndarray):
+        """
+        add a new cluster weight
+
+        Parameters:
+        - new_w: new cluster weight to add
+
+        """
+        if len(self.W) == 0:
+            self.adjacency = np.zeros((1, 1))
+        else:
+            self.adjacency = np.pad(self.adjacency, ((0, 1), (0, 1)), "constant")
+        self._permanent_mask = np.pad(self._permanent_mask, (0, 1), "constant")
+        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)
+        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.adjacency = self.adjacency[perm_labels][:, perm_labels]
+        self._permanent_mask = self._permanent_mask[perm_labels]
+
+        label_map = {
+            label: np.where(perm_labels == label)[0][0]
+            for label in np.unique(self.labels_)
+            if label in perm_labels
+        }
+
+        for i, x in enumerate(X):
+            if self.labels_[i] in label_map:
+                self.labels_[i] = label_map[self.labels_[i]]
+            elif len(self.W) > 0:
+                # this is a more flexible approach than that described in the paper
+                self.labels_[i] = self.step_pred(x)
+            else:
+                self.labels_[i] = -1
+
+    def post_step_fit(self, X: np.ndarray):
+        """
+        Function called after each sample fit. Used for cluster pruning
+
+        Parameters:
+        - X: data set
+
+        """
+        if self.sample_counter_ > 0 and self.sample_counter_ % self.tau == 0:
+            self.prune(X)
+
+    def step_fit(self, x: np.ndarray, match_reset_func: Optional[Callable] = None) -> 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
+
+        """
+        self.sample_counter_ += 1
+        resonant_c: int = -1
+
+        if len(self.W) == 0:
+            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)
+            return 0
+        else:
+            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(T > 0):
+                c_ = int(np.argmax(T))
+                w = self.W[c_]
+                cache = T_cache[c_]
+                m, cache = self.match_criterion_bin(x, w, 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:
+                    if resonant_c < 0:
+                        params = self.params
+                    else:
+                        params = dict(self.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_})
+                    )
+                    self.set_weight(c_, new_w)
+                    if resonant_c < 0:
+                        resonant_c = c_
+                        T[c_] = -1
+                    else:
+                        return resonant_c
+                else:
+                    T[c_] = -1
+
+            if resonant_c < 0:
+                c_new = len(self.W)
+                w_new = self.new_weight(x, self.params)
+                self.add_weight(w_new)
+                return c_new
+
+            return resonant_c
+
+    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
+
+        """
+        try:
+            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
diff --git a/artlib/topological/__init__.py b/artlib/topological/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e6d4587
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,39 @@
+[tool.poetry]
+name = "artlib"
+version = "0.1.0"
+description = "A Python library for Adaptive Resonance Theory (ART) algorithms."
+authors = ["Niklas M. Melton <niklasmelton@gmail.com>"]
+license = "MIT"
+homepage = "https://github.com/NiklasMelton/AdaptiveResonanceLib"
+repository = "https://github.com/NiklasMelton/AdaptiveResonanceLib"
+documentation = "https://github.com/NiklasMelton/AdaptiveResonanceLib"
+readme = "README.md"
+keywords = ["adaptive resonance theory", "ART", "machine learning", "neural networks", "clustering"]
+
+classifiers = [
+  "Development Status :: 3 - Alpha",
+  "Intended Audience :: Developers",
+  "Intended Audience :: Science/Research",
+  "License :: OSI Approved :: MIT License",
+  "Operating System :: OS Independent",
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
+  "Topic :: Scientific/Engineering :: Artificial Intelligence",
+]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+numpy = ">=1.19.4"
+scipy = ">=1.5.4"
+scikit-learn = ">=1.0"
+matplotlib = ">=3.3.3"
+
+[tool.poetry.dev-dependencies]
+pytest = "^6.2.2"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/templates/ART_template.py b/templates/ART_template.py
new file mode 100644
index 0000000..ba74056
--- /dev/null
+++ b/templates/ART_template.py
@@ -0,0 +1,111 @@
+import numpy as np
+from typing import Optional
+from artlib import BaseART
+from artlib.common.utils import normalize
+
+def prepare_data(data: np.ndarray) -> np.ndarray:
+    normalized = normalize(data)
+    return normalized
+
+
+class myART(BaseART):
+    # template for ART module
+
+    @staticmethod
+    def validate_params(params: dict):
+        """
+        validate clustering parameters
+
+        Parameters:
+        - params: dict containing parameters for the algorithm
+
+        """
+        raise NotImplementedError
+
+    def validate_data(self, X: np.ndarray):
+        """
+        validates the data prior to clustering
+
+        Parameters:
+        - X: data set
+
+        """
+        raise NotImplementedError
+
+    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
+
+        """
+        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
+
+        """
+        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
+
+        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
+
+        """
+        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
+
+        Returns:
+            updated cluster weight, cache used for later processing
+
+        """
+        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
+
+        Returns:
+            updated cluster weight
+
+        """
+        raise NotImplementedError


Test patch:
diff --git a/examples/test_art1.py b/examples/test_art1.py
new file mode 100644
index 0000000..11a77d4
--- /dev/null
+++ b/examples/test_art1.py
@@ -0,0 +1,30 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import ART1, normalize
+
+
+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)
+    print("Data has shape:", data.shape)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.7,
+        "beta": 1.0,
+        "L": 1.0
+    }
+    cls = ART1(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_art2.py b/examples/test_art2.py
new file mode 100644
index 0000000..e84fa58
--- /dev/null
+++ b/examples/test_art2.py
@@ -0,0 +1,36 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+from artlib import ART2A, normalize
+
+"""
+==================================================================
+DISCLAIMER: DO NOT USE ART2!!!
+IT DOES NOT WORK
+It is provided for completeness only.
+Stephan Grossberg himself has said ART2 does not work.
+==================================================================
+"""
+
+def cluster_blobs():
+    data, target = make_blobs(n_samples=150, centers=2, cluster_std=0.50, random_state=0, shuffle=False)
+    print("Data has shape:", data.shape)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.2,
+        "alpha": 0.0,
+        "beta": 1.0,
+    }
+    cls = ART2A(**params)
+    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/examples/test_artmap.py b/examples/test_artmap.py
new file mode 100644
index 0000000..542037b
--- /dev/null
+++ b/examples/test_artmap.py
@@ -0,0 +1,76 @@
+import numpy as np
+from sklearn.datasets import load_iris, make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import FuzzyART, SimpleARTMAP
+
+
+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 = FuzzyART.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)
+
+    params = {
+        "rho": 0.0,
+        "alpha": 0.0,
+        "beta": 1.0
+    }
+    art = FuzzyART(**params)
+
+    cls = SimpleARTMAP(art)
+
+    cls = cls.fit(X_train, y_train)
+
+    print(f"{len(np.unique(cls.labels_a))} clusters found")
+
+    _, y_pred = cls.predict(X_test)
+
+    print(classification_report(y_test, y_pred))
+
+    mapper = umap.UMAP().fit(X_test)
+
+    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)
+    print("Data has shape:", data.shape)
+
+    X = FuzzyART.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.9,
+        "alpha": 0.0,
+        "beta": 1.0
+    }
+    art = FuzzyART(**params)
+
+    cls = SimpleARTMAP(art)
+
+    cls = cls.fit(X, target)
+    y = cls.labels_
+
+    print(f"{cls.module_a.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+
+    plt.show()
+
+
+def main():
+    # cluster_iris()
+    cluster_blobs()
+
+
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/examples/test_bartmap.py b/examples/test_bartmap.py
new file mode 100644
index 0000000..2661fe6
--- /dev/null
+++ b/examples/test_bartmap.py
@@ -0,0 +1,40 @@
+from sklearn.datasets import make_checkerboard
+import matplotlib.pyplot as plt
+
+from artlib import BARTMAP, FuzzyART, normalize
+
+
+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
+    )
+    print("Data has shape:", data.shape)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    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.fit(X)
+
+    print(f"{cls.n_row_clusters} row clusters found")
+    print(f"{cls.n_column_clusters} column clusters found")
+
+    cls.visualize()
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_checkerboard()
diff --git a/examples/test_bayesian_art.py b/examples/test_bayesian_art.py
new file mode 100644
index 0000000..fe2e6a9
--- /dev/null
+++ b/examples/test_bayesian_art.py
@@ -0,0 +1,39 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+import path
+import sys
+
+# directory reach
+directory = path.Path(__file__).abspath()
+
+print(directory.parent)
+# setting path
+sys.path.append(directory.parent.parent)
+
+from artlib import BayesianART
+from artlib.common.utils import normalize
+import numpy as np
+
+
+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)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 2e-5,
+        "cov_init": np.array([[1e-4, 0.0], [0.0, 1e-4]]),
+    }
+    cls = BayesianART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_dual_vigilance_art.py b/examples/test_dual_vigilance_art.py
new file mode 100644
index 0000000..f6e323e
--- /dev/null
+++ b/examples/test_dual_vigilance_art.py
@@ -0,0 +1,31 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import DualVigilanceART, 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)
+
+    X = FuzzyART.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.85,
+        "alpha": 0.8,
+        "beta": 1.0
+    }
+    base_art = FuzzyART(**params)
+    cls = DualVigilanceART(base_art, 0.78)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+    print(f"{cls.base_module.n_clusters} internal clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_ellipsoid_art.py b/examples/test_ellipsoid_art.py
new file mode 100644
index 0000000..6821da4
--- /dev/null
+++ b/examples/test_ellipsoid_art.py
@@ -0,0 +1,31 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import EllipsoidART, normalize
+
+
+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)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.2,
+        "alpha": 0.0,
+        "beta": 1.0,
+        "r_hat": 0.6,
+        "mu": 0.8
+    }
+    cls = EllipsoidART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_fusion_art.py b/examples/test_fusion_art.py
new file mode 100644
index 0000000..3492035
--- /dev/null
+++ b/examples/test_fusion_art.py
@@ -0,0 +1,38 @@
+import numpy as np
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import FusionART, 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)
+
+    data_channel_a = data[:,0].reshape((-1,1))
+    data_channel_b = data[:,1].reshape((-1,1))
+
+    X_channel_a = FuzzyART.prepare_data(data_channel_a)
+    X_channel_b = FuzzyART.prepare_data(data_channel_b)
+
+    X = np.hstack([X_channel_a, X_channel_b])
+    print("Prepared data has shape:", X.shape)
+
+    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=[0.5, 0.5], channel_dims=[2,2])
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(data, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_fuzzy_art.py b/examples/test_fuzzy_art.py
new file mode 100644
index 0000000..b5729be
--- /dev/null
+++ b/examples/test_fuzzy_art.py
@@ -0,0 +1,29 @@
+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)
+
+    X = FuzzyART.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.7,
+        "alpha": 0.0,
+        "beta": 1.0
+    }
+    cls = FuzzyART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_gaussian_art.py b/examples/test_gaussian_art.py
new file mode 100644
index 0000000..9bba697
--- /dev/null
+++ b/examples/test_gaussian_art.py
@@ -0,0 +1,29 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+import numpy as np
+
+from artlib import GaussianART, normalize
+
+
+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)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.15,
+        "sigma_init": np.array([0.5, 0.5]),
+    }
+    cls = GaussianART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_grid_search_cv.py b/examples/test_grid_search_cv.py
new file mode 100644
index 0000000..370b1d2
--- /dev/null
+++ b/examples/test_grid_search_cv.py
@@ -0,0 +1,49 @@
+from sklearn.datasets import load_iris
+from sklearn.model_selection import GridSearchCV, train_test_split
+from sklearn.metrics import classification_report
+
+from artlib import HypersphereART, SimpleARTMAP
+
+
+def grid_search_blobs():
+    data, target = load_iris(return_X_y=True)
+    print("Data has shape:", data.shape)
+
+    X = HypersphereART.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)
+
+    base_params = {
+        "rho": 0.3,
+        "alpha": 0.0,
+        "beta": 1.0,
+        "r_hat": 0.8
+    }
+    art = HypersphereART(**base_params)
+    cls = SimpleARTMAP(art)
+    # print(cls.get_params(deep=False))
+    # raise ValueError
+
+    param_grid = {
+        "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]
+    }
+
+    grid = GridSearchCV(cls, param_grid, refit=True, verbose=3, n_jobs=1)
+
+    grid.fit(X_train, y_train)
+
+    # print best parameter after tuning
+    print("Best parameters:")
+    print(grid.best_params_)
+    grid_predictions = grid.predict(X_test)
+
+    # print classification report
+    print(classification_report(y_test, grid_predictions))
+
+
+if __name__ == "__main__":
+    grid_search_blobs()
diff --git a/examples/test_hypersphere_art.py b/examples/test_hypersphere_art.py
new file mode 100644
index 0000000..e1d76c1
--- /dev/null
+++ b/examples/test_hypersphere_art.py
@@ -0,0 +1,30 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import HypersphereART, normalize
+
+
+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)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.7,
+        "alpha": 0.0,
+        "beta": 1.0,
+        "r_hat": 0.8
+    }
+    cls = HypersphereART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_quadratic_neuron_art.py b/examples/test_quadratic_neuron_art.py
new file mode 100644
index 0000000..099f655
--- /dev/null
+++ b/examples/test_quadratic_neuron_art.py
@@ -0,0 +1,31 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import QuadraticNeuronART, normalize
+
+
+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)
+
+    X = normalize(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.9,
+        "s_init": 0.9,
+        "lr_b": 0.1,
+        "lr_w": 0.1,
+        "lr_s": 0.1
+    }
+    cls = QuadraticNeuronART(**params)
+    y = cls.fit_predict(X)
+
+    print(f"{cls.n_clusters} clusters found")
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file
diff --git a/examples/test_smart.py b/examples/test_smart.py
new file mode 100644
index 0000000..090f147
--- /dev/null
+++ b/examples/test_smart.py
@@ -0,0 +1,34 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import FuzzyART, SMART
+
+
+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)
+
+    X = FuzzyART.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    base_params = {
+        "alpha": 0.0,
+        "beta": 1.0
+    }
+    rho_values = [0.7, 0.85, 0.9]
+    cls = SMART(FuzzyART, rho_values, base_params)
+
+    cls = cls.fit(X)
+    y = cls.layers[0].labels_
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+def main():
+    cluster_blobs()
+
+
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/examples/test_topo_art.py b/examples/test_topo_art.py
new file mode 100644
index 0000000..d346d0e
--- /dev/null
+++ b/examples/test_topo_art.py
@@ -0,0 +1,35 @@
+from sklearn.datasets import make_blobs
+import matplotlib.pyplot as plt
+
+from artlib import TopoART, 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)
+
+    X = FuzzyART.prepare_data(data)
+    print("Prepared data has shape:", X.shape)
+
+    params = {
+        "rho": 0.6,
+        "alpha": 0.8,
+        "beta": 1.0
+    }
+    base_art = FuzzyART(**params)
+    cls = TopoART(base_art, betta_lower=0.3, tau=150, phi=35)
+    cls = cls.fit(X, max_iter=5)
+    y = cls.labels_
+
+    print(f"{cls.n_clusters} clusters found")
+    print(f"{cls.base_module.n_clusters} internal clusters found")
+
+    print("Adjacency Matrix:")
+    print(cls.adjacency)
+
+    cls.visualize(X, y)
+    plt.show()
+
+
+if __name__ == "__main__":
+    cluster_blobs()
\ No newline at end of file