Repository: voxel51/fiftyone-brain (language: python)

Issue:
Add eta storage to dev.txt
Adds `voxel51-eta[storage]` to dev requirements for uniqueness test


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8d0a65d1..bb71480a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,7 +22,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v2
         with:
-          python-version: 3.8
+          python-version: 3.9
       - name: Check Python version
         run: |
           python --version
@@ -42,7 +42,7 @@ jobs:
         run: |
           python setup.py sdist bdist_wheel
       - name: Upload wheel
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v4
         with:
           path: dist
           retention-days: 1
@@ -51,13 +51,16 @@ jobs:
     needs: [build]
     runs-on: ubuntu-20.04
     env:
+      FIFTYONE_DATASET_ZOO_DIR: ${{ github.workspace }}/.fiftyone
       FIFTYONE_DO_NOT_TRACK: true
+      FIFTYONE_MODEL_ZOO_DIR: ${{ github.workspace }}/.fiftyone
     strategy:
       fail-fast: false
       matrix:
         python:
-          - 3.8
+          - "3.9"
           - "3.10"
+          - "3.11"
     steps:
       - name: Clone fiftyone-brain
         uses: actions/checkout@v1
@@ -71,7 +74,7 @@ jobs:
         run: |
           pip install --upgrade pip setuptools wheel
       - name: Download fiftyone-brain wheel
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v4
         with:
           path: dist
       - name: Install fiftyone
@@ -103,6 +106,13 @@ jobs:
       - name: Install test dependencies
         run: |
           pip install imageio pytest torch torchvision
+      - name: Cache Zoo
+        id: fiftyone-cache
+        uses: actions/cache@v4
+        with:
+          path: |
+            .fiftyone
+          key: zoo-${{ hashFiles('tests/**') }}
       - name: Run tests
         run: |
           pytest --verbose tests/ --ignore tests/intensive/
@@ -113,7 +123,7 @@ jobs:
     runs-on: ubuntu-20.04
     steps:
       - name: Download wheels
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v4
         with:
           path: dist
       - name: Install dependencies
diff --git a/.gitignore b/.gitignore
index 601c9944..31f0c20d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,18 +1,20 @@
-
-**/*.DS_store
-
 __pycache__
-*.py[cod]
+.DS_store
+.ipynb_checkpoints
 
-build/
-dist/
+*~
 *.egg-info
+*.py[cod]
+*.pth
+*.swp
+
+.idea
 .project
 .pydevproject
-**/*.ipynb_checkpoints
-.idea
 
-*~
+build/
+dist/
 
 /fiftyone/brain/internal/models/cache/**/*
 !/fiftyone/brain/internal/models/cache/manifest.json
+*.pth
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..34f0c669
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,29 @@
+# Contributing to FiftyOne Brain
+
+All Brain contributions should follow the practices established in
+[FiftyOne](https://github.com/voxel51/fiftyone/blob/develop/CONTRIBUTING.md).
+
+## Adding new public methods to the Brain package
+
+The `fiftyone.brain` package should expose all core user-functionality at the
+base level. For example, for hardness, the user should be able to execute calls
+in the following way:
+
+```py
+# Users should be able to do this
+import fiftyone.brain as fob
+
+fob.compute_hardness(...)
+
+# And NOT have to do this
+import fiftyone.brain.hardness as fobh
+
+fobh.compute_hardness(...)
+```
+
+To achieve this, follow the existing pattern of declaring new public methods in
+[`fiftyone/brain/__init__.py`](https://github.com/voxel51/fiftyone-brain/blob/develop/fiftyone/brain/__init__.py).
+
+Be sure to include a detailed docstring for all methods in this file, as they
+are pulled in by FiftyOne documentation builds and are made available in the
+[public docs](https://docs.voxel51.com/api/fiftyone.brain.html).
diff --git a/LICENSE b/LICENSE
index a93c2783..a89f291b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,163 +1,202 @@
-Copyright 2022, Voxel51, Inc.
-All rights reserved.
 
-FREEWARE LICENSE AND END-USER LICENSE AGREEMENT
-For the FiftyOne Brain
-
-TL;DR
-
-The functionality in the FiftyOne Brain software package is closed-source,
-freeware. You have permission to use it for most purposes: you may not attempt
-to decompile it or resell it, but the output of it can be used for commercial
-or non-commercial purposes. Voxel51, Inc. has no liability to the accuracy or
-veracity of the findings resulting from the use of the FiftyOne Brain.
-
-NOTICE TO USER
-
-Please read this carefully. By using all or any portion of the Software you
-accept all the terms and conditions of this Agreement. If you do not agree, do
-not use this Software.
-
-1. DEFINITIONS
-
-When used in this Agreement, the following terms shall have the respective
-meanings indicated, such meanings to be applicable to both the singular and
-plural forms of the terms defined:
-
--   "Licensor" means Voxel51, Inc.
-
--   "Licensee" means You or Your Company, unless otherwise indicated.
-
--   "Software" means (a) all of the contents of the files with which this
-    Agreement is provided, including but not limited to (i) registration
-    information, i.e. License key which is unique for a registration name of
-    the Licensee; (ii) related explanatory written materials or files
-    ("Documentation"); (iii) Software files, configuration files, setup files
-    and code samples (if any); (iv) Data files and model files; and (b)
-    upgrades, modified versions, updates, additions, and copies of the
-    Software, if any, licensed to you by Voxel51, Inc. (collectively,
-    "Updates").
-
--   "Use" or "Using" means to access, install, download, copy or otherwise
-    benefit from using the functionality of the Software in accordance with
-    the Documentation.
-
--   "System" means Windows OS, GNU/Linux or Mac OS X, or any virtual machine.
-
-2. GENERAL USE
-
-You are granted a non-exclusive License to Use the downloaded Software for any
-purposes for an unlimited period of time.
-
-The software product under this License is provided free of charge. Even though
-a license fee is not paid for the use of such software, it does not mean that
-there are no conditions for using such software.
-
-2.1. The Software may be installed and Used by the Licensee for any legal
-purpose in which the User has direct value from the use of and output of the
-Software.
-
-2.2. The Software may be installed and Used by the Licensee on any number of
-systems. On each system, the software may be used by individuals and be used by
-one GPU card at any time.
-
-2.3. The Software may not be resold as a service in any capacity.
-
-2.4. The Software can be copied and distributed under the condition that
-original copyright notice and disclaimer of warranty will stay intact and the
-Licensee will not charge money or fees for the Software product, except to
-cover distribution costs.
-
-2.6. The Licensee will not have any proprietary rights in and to the Software.
-The Licensee acknowledges and agrees that the Licensor retains all copyrights
-and other proprietary rights in and to the Software.
-
-2.7 Use within the scope of this License is free of charge and no royalty or
-licensing fees shall be paid by the Licensee.
-
-3. INTELLECTUAL PROPERTY RIGHTS
-
-3.1 This License does not transmit any intellectual rights on the Software. The
-Software and any copies that the Licensee is authorized by the Licensor to make
-are the intellectual property of and are owned by the Licensor.
-
-3.2 The Software is protected by copyright, including without limitation by
-Copyright Law and international treaty provisions.
-
-3.3 Any copies that the Licensee is permitted to make pursuant to this
-Agreement must contain the same copyright and other proprietary notices that
-appear on or in the Software.
-
-3.4 The structure, organization and code of the Software are the valuable trade
-secrets and confidential information of the Licensor. The Licensee agrees not
-to decompile, disassemble or otherwise attempt to discover the source code of
-the Software.
-
-3.5 Any attempts to reverse-engineer, copy, clone, modify or alter in any way
-the installer program without the Licensor’s specific approval are strictly
-prohibited. The Licensee is not authorized to use any plug-in or enhancement
-that permits to save modifications to a file with software licensed and
-distributed by the Licensor.
-
-3.6 Trademarks shall be used in accordance with accepted trademark practice,
-including identification of trademarks owners’ names. Trademarks can only be
-used to identify printed output produced by the Software and such use of any
-trademark does not give the Licensee any rights of ownership in that trademark.
-
-4. WARRANTY
-
-4.1 The Licensor warrants that:
-
-4.1.1 The Licensor owns the Software and documentation and/or is in possession
-of valid and existing licenses that support the terms of this Agreement;
-
-4.1.2 the Software conforms to specifications and functionality as specified
-in Documentation;
-
-4.1.3 to the best of the Licensor’s knowledge, the Software does not infringe
-upon or violate any intellectual property right of any third party;
-
-4.1.4 the Software does not contain any routine, intentionally designed by the
-Licensor to disable a computer program, or computer instructions that may
-alter, destroy or inhibit the processing environment.
-
-4.2 Except those warranties specified in section 4.1 above, the Software is
-being delivered to the Licensee "AS IS" and the Licensor makes no warranty as
-to its use or performance.
-
-The Licensor does not and cannot warrant the performance or results the
-Licensee may obtain by using the Software. The entire risk arising out of use
-or performance of the Software remains with the Licensee.
-
-The Licensor gives no warranty, express or implied, that (i) the Software will
-be of satisfactory quality, suitable for any particular purpose or for any
-particular use under specified conditions, notwithstanding that such purpose,
-use, or conditions may be known to the Licensor; or (ii) that the Software will
-operate error free or without interruption or that any errors will be
-corrected.
-
-5. LIMITATION OF LIABILITY
-
-In no event will the Licensor be liable for any damages, claims or costs
-whatsoever or any consequential, indirect, incidental damages, or any lost
-profits or lost savings, even if the Licensor has been advised of the
-possibility of such loss, damages, claims or costs or for any claim by any
-third party.
-
-In no event will the Licensee be liable to the Licensor on condition that the
-Licensee complies with all terms and conditions stated in this License.
-
-6. NON-WAIVER
-
-If a portion of this agreement is held unenforceable, the remainder shall be
-valid. It means that if one section of the Agreement is not lawful, the rest of
-the Agreement is still in force. A party’s failure to exercise any right under
-this Agreement will not constitute a waiver of (a) any other terms or
-conditions of this Agreement, or (b) a right at any time thereafter to require
-exact and strict compliance with the terms of this Agreement.
-
-7. ATTRIBUTION
-
-Any screenshot of the output of the Software or result obtained from the
-Software that is used in reporting, marketing whether print or digital will
-acknowledge the use of FiftyOne by Voxel51.
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction,
+and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by
+the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all
+other entities that control, are controlled by, or are under common
+control with that entity. For the purposes of this definition,
+"control" means (i) the power, direct or indirect, to cause the
+direction or management of such entity, whether by contract or
+otherwise, or (ii) ownership of fifty percent (50%) or more of the
+outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity
+exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications,
+including but not limited to software source code, documentation
+source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical
+transformation or translation of a Source form, including but
+not limited to compiled object code, generated documentation,
+and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or
+Object form, made available under the License, as indicated by a
+copyright notice that is included in or attached to the work
+(an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object
+form, that is based on (or derived from) the Work and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship. For the purposes
+of this License, Derivative Works shall not include works that remain
+separable from, or merely link (or bind by name) to the interfaces of,
+the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including
+the original version of the Work and any modifications or additions
+to that Work or Derivative Works thereof, that is intentionally
+submitted to Licensor for inclusion in the Work by the copyright owner
+or by an individual or Legal Entity authorized to submit on behalf of
+the copyright owner. For the purposes of this definition, "submitted"
+means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems,
+and issue tracking systems that are managed by, or on behalf of, the
+Licensor for the purpose of discussing and improving the Work, but
+excluding communication that is conspicuously marked or otherwise
+designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity
+on behalf of whom a Contribution has been received by Licensor and
+subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+this License, each Contributor hereby grants to You a perpetual,
+worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the
+Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+this License, each Contributor hereby grants to You a perpetual,
+worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+(except as stated in this section) patent license to make, have made,
+use, offer to sell, sell, import, and otherwise transfer the Work,
+where such license applies only to those patent claims licensable
+by such Contributor that are necessarily infringed by their
+Contribution(s) alone or by combination of their Contribution(s)
+with the Work to which such Contribution(s) was submitted. If You
+institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work
+or a Contribution incorporated within the Work constitutes direct
+or contributory patent infringement, then any patent licenses
+granted to You under this License for that Work shall terminate
+as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+Work or Derivative Works thereof in any medium, with or without
+modifications, and in Source or Object form, provided that You
+meet the following conditions:
+
+(a) You must give any other recipients of the Work or
+Derivative Works a copy of this License; and
+
+(b) You must cause any modified files to carry prominent notices
+stating that You changed the files; and
+
+(c) You must retain, in the Source form of any Derivative Works
+that You distribute, all copyright, patent, trademark, and
+attribution notices from the Source form of the Work,
+excluding those notices that do not pertain to any part of
+the Derivative Works; and
+
+(d) If the Work includes a "NOTICE" text file as part of its
+distribution, then any Derivative Works that You distribute must
+include a readable copy of the attribution notices contained
+within such NOTICE file, excluding those notices that do not
+pertain to any part of the Derivative Works, in at least one
+of the following places: within a NOTICE text file distributed
+as part of the Derivative Works; within the Source form or
+documentation, if provided along with the Derivative Works; or,
+within a display generated by the Derivative Works, if and
+wherever such third-party notices normally appear. The contents
+of the NOTICE file are for informational purposes only and
+do not modify the License. You may add Your own attribution
+notices within Derivative Works that You distribute, alongside
+or as an addendum to the NOTICE text from the Work, provided
+that such additional attribution notices cannot be construed
+as modifying the License.
+
+You may add Your own copyright statement to Your modifications and
+may provide additional or different license terms and conditions
+for use, reproduction, or distribution of Your modifications, or
+for any such Derivative Works as a whole, provided Your use,
+reproduction, and distribution of the Work otherwise complies with
+the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+any Contribution intentionally submitted for inclusion in the Work
+by You to the Licensor shall be under the terms and conditions of
+this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify
+the terms of any separate license agreement you may have executed
+with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+names, trademarks, service marks, or product names of the Licensor,
+except as required for reasonable and customary use in describing the
+origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+agreed to in writing, Licensor provides the Work (and each
+Contributor provides its Contributions) on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+implied, including, without limitation, any warranties or conditions
+of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+PARTICULAR PURPOSE. You are solely responsible for determining the
+appropriateness of using or redistributing the Work and assume any
+risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+whether in tort (including negligence), contract, or otherwise,
+unless required by applicable law (such as deliberate and grossly
+negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special,
+incidental, or consequential damages of any character arising as a
+result of this License or out of the use or inability to use the
+Work (including but not limited to damages for loss of goodwill,
+work stoppage, computer failure or malfunction, or any and all
+other commercial damages or losses), even if such Contributor
+has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+the Work or Derivative Works thereof, You may choose to offer,
+and charge a fee for, acceptance of support, warranty, indemnity,
+or other liability obligations and/or rights consistent with this
+License. However, in accepting such obligations, You may act only
+on Your own behalf and on Your sole responsibility, not on behalf
+of any other Contributor, and only if You agree to indemnify,
+defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason
+of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following
+boilerplate notice, with the fields enclosed by brackets "[]"
+replaced with your own identifying information. (Don't include
+the brackets!)  The text should be enclosed in the appropriate
+comment syntax for the file format. We also recommend that a
+file or class name and description of purpose be included on the
+same "printed page" as the copyright notice for easier
+identification within third-party archives.
+
+Copyright 2017-2022, Voxel51, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
\ No newline at end of file
diff --git a/PYPI_README.md b/PYPI_README.md
deleted file mode 100644
index 30ef49dd..00000000
--- a/PYPI_README.md
+++ /dev/null
@@ -1,68 +0,0 @@
-<div align="center">
-
-<h1>
-    FiftyOne Brain
-</h1>
-
-**The brains behind
-[FiftyOne](https://github.com/voxel51/fiftyone).**
-
-<p align="center">
-  <a href="https://voxel51.com/docs/fiftyone/user_guide/brain.html">Docs</a> •
-  <a href="https://colab.research.google.com/github/voxel51/fiftyone-examples/blob/master/examples/quickstart.ipynb">Try it Now</a> •
-  <a href="https://voxel51.com/docs/fiftyone/tutorials/index.html">Tutorials</a> •
-  <a href="https://github.com/voxel51/fiftyone-examples">Examples</a>
-</p>
-
-[![PyPI python](https://img.shields.io/pypi/pyversions/fiftyone-brain)](https://pypi.org/project/fiftyone-brain)
-[![PyPI version](https://badge.fury.io/py/fiftyone-brain.svg)](https://pypi.org/project/fiftyone-brain)
-[![Slack](https://img.shields.io/badge/Slack-4A154B?logo=slack&logoColor=white)](https://join.slack.com/t/fiftyone-users/shared_invite/zt-gtpmm76o-9AjvzNPBOzevBySKzt02gg)
-[![Medium](https://img.shields.io/badge/Medium-12100E?logo=medium&logoColor=white)](https://medium.com/voxel51)
-[![Twitter](https://img.shields.io/badge/Twitter-1DA1F2?logo=twitter&logoColor=white)](https://twitter.com/voxel51)
-
-<img src="https://user-images.githubusercontent.com/25985824/104953482-7ea97a00-5994-11eb-8cc3-f648c15502b1.png" alt="fiftyone-brain.png">
-
-</div>
-
----
-
-The [FiftyOne Brain](https://voxel51.com/docs/fiftyone/user_guide/brain.html)
-provides powerful machine learning techniques that are designed to transform
-how you curate your data from an art into a measurable science.
-
-The FiftyOne Brain is a separate Python package that is bundled with
-[FiftyOne](https://voxel51.com/docs/fiftyone). Although it is closed-source, it
-is licensed as freeware, and you have permission to use it for commercial or
-non-commercial purposes. See the license below for more details.
-
-The FiftyOne Brain methods are useful across the stages of the machine learning
-workflow:
-
--   **[Uniqueness](https://voxel51.com/docs/fiftyone/user_guide/brain.html#image-uniqueness):**
-    During the training loop for a model, the best results will be seen when
-    training on unique data. The FiftyOne Brain provides a uniqueness measure
-    for images that compare the content of every image in a FiftyOne Dataset
-    with all other images. Uniqueness operates on raw images and does not
-    require any prior annotation on the data. It is hence very useful in the
-    early stages of the machine learning workflow when you are likely asking
-    "What data should I select to annotate?"
-
--   **[Mistakenness](https://voxel51.com/docs/fiftyone/user_guide/brain.html#label-mistakes):**
-    Annotations mistakes create an artificial ceiling on the performance of
-    your models. However, finding these mistakes by hand is at least as arduous
-    as the original annotation was, especially in cases of larger datasets. The
-    FiftyOne Brain provides a quantitative mistakenness measure to identify
-    possible label mistakes. Mistakenness operates on labeled images and
-    requires the logit-output of your model predictions in order to provide
-    maximum efficacy. It also works on detection datasets to find missed
-    objects, incorrect annotations, and localization issues.
-
--   **[Hardness](https://voxel51.com/docs/fiftyone/user_guide/brain.html#sample-hardness):**
-    While a model is training, it will learn to understand attributes of
-    certain samples faster than others. The FiftyOne Brain provides a hardness
-    measure that calculates how easy or difficult it is for your model to
-    understand any given sample. Mining hard samples is a tried and true
-    measure of mature machine learning processes. Use your current model
-    instance to compute predictions on unlabeled samples to determine which are
-    the most valuable to have annotated and fed back into the system as
-    training samples, for example.
diff --git a/README.md b/README.md
index d3065c44..e22241fa 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,71 @@
-# FiftyOne Brain
+<div align="center">
+<p align="center">
 
-The brains behind [FiftyOne](https://github.com/voxel51/fiftyone).
+<img src="https://github.com/user-attachments/assets/17afdf93-289c-40f1-805c-06344f095cf6" height="55px">
 
-## Repository Layout
+**Open Source AI from [Voxel51](https://voxel51.com)**
 
--   `docs/` documentation about the repository and project
+<!-- prettier-ignore -->
+<a href="https://voxel51.com/fiftyone">FiftyOne Website</a> •
+<a href="https://voxel51.com/docs/fiftyone">FiftyOne Docs</a> •
+<a href="https://docs.voxel51.com/user_guide_brain.html">FiftyOne Brain Docs</a> •
+<a href="https://voxel51.com/blog/">Blog</a> •
+<a href="https://slack.voxel51.com">Community</a>
 
--   `experiments/` internal-only examples that demonstrate concrete value-add
-    of the FiftyOne Brain
+[![PyPI python](https://img.shields.io/pypi/pyversions/fiftyone-brain)](https://pypi.org/project/fiftyone-brain)
+[![PyPI version](https://badge.fury.io/py/fiftyone.svg)](https://pypi.org/project/fiftyone-brain)
+[![Downloads](https://static.pepy.tech/badge/fiftyone-brain)](https://pepy.tech/project/fiftyone-brain)
+[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
+[![Slack](https://img.shields.io/badge/Slack-4A154B?logo=slack&logoColor=white)](https://slack.voxel51.com)
+[![Medium](https://img.shields.io/badge/Medium-12100E?logo=medium&logoColor=white)](https://medium.com/voxel51)
+[![Mailing list](http://bit.ly/2Md9rxM)](https://share.hsforms.com/1zpJ60ggaQtOoVeBqIZdaaA2ykyk)
+[![Twitter](https://img.shields.io/twitter/follow/Voxel51?style=social)](https://twitter.com/voxel51)
 
--   `fiftyone/brain/` definition of the `fiftyone.brain` package
+</p>
+</div>
 
-    -   `fiftyone/brain/internal/` all propreitary internal code powering the
-        public namespace of the Brain (TODO: refactor for open source)
+---
 
--   `production/` work needed to generate production models, code, etc.,
-    associated with the Brain. It contains scripts and such for repeatable,
-    versioned model training for uniqueness, for example
+FiftyOne Brain contains the open source AI/ML capabilities for the
+[FiftyOne ecosystem](https://github.com/voxel51/fiftyone), enabling users to
+automatically analyze and manipulate their datasets and models. FiftyOne Brain
+includes features like visual similarity search, query by text, finding unique
+and representative samples, finding media quality problems and annotation
+mistakes, and more 🚀
 
--   `requirements/` Python requirements for the project
+## Documentation
 
--   `tests/` tests for the various components of the Brain
+Public documentation for the FiftyOne Brain is
+[available here](https://docs.voxel51.com/user_guide/brain.html).
 
 ## Installation
 
-Clone the repository:
+The FiftyOne Brain is distributed via the `fiftyone-brain` package, and a
+suitable version is automatically included with every `fiftyone` install:
+
+```shell
+pip install fiftyone
+pip show fiftyone-brain
+```
+
+### Installing from source
+
+If you wish to do a source install of the latest FiftyOne Brain version, simply
+clone this repository:
 
 ```shell
 git clone https://github.com/voxel51/fiftyone-brain
 cd fiftyone-brain
 ```
 
-and install it:
+and run the install script:
 
 ```shell
+# Mac or Linux
 bash install.bash
+
+# Windows
+.\install.bat
 ```
 
 ### Developer installation
@@ -43,15 +74,36 @@ If you are a developer contributing to this repository, you should perform a
 developer installation using the `-d` flag of the install script:
 
 ```shell
+# Mac or Linux
 bash install.bash -d
+
+# Windows
+.\install.bat -d
 ```
 
-You should also checkout the
-[Developer's Guide](https://github.com/voxel51/fiftyone-brain/blob/develop/docs/dev_guide.md)
-to get started.
+Check out the [contribution guide](CONTRIBUTING.md) to get started.
 
 ## Uninstallation
 
 ```shell
 pip uninstall fiftyone-brain
 ```
+
+## Repository layout
+
+-   `fiftyone/brain/` definition of the `fiftyone.brain` namespace
+-   `requirements/` Python requirements for the project
+-   `tests/` tests for the various components of the Brain
+
+## Citation
+
+If you use the FiftyOne Brain in your research, please cite the project:
+
+```bibtex
+@article{moore2020fiftyone,
+  title={FiftyOne},
+  author={Moore, B. E. and Corso, J. J.},
+  journal={GitHub. Note: https://github.com/voxel51/fiftyone-brain},
+  year={2020}
+}
+```
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
new file mode 100644
index 00000000..1c598141
--- /dev/null
+++ b/STYLE_GUIDE.md
@@ -0,0 +1,4 @@
+# FiftyOne Brain Style Guide
+
+The Brain follows the same style guidelines as
+[FiftyOne](https://github.com/voxel51/fiftyone/blob/develop/STYLE_GUIDE.md).
diff --git a/docs/dev_guide.md b/docs/dev_guide.md
deleted file mode 100644
index 36806d88..00000000
--- a/docs/dev_guide.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# FiftyOne Brain Developer's Guide
-
-This document describes best practices for contributing to the `fiftyone-brain`
-codebase.
-
-We currently follow all of the practices established in
-[ETA](https://github.com/voxel51/eta) for the Python code in this repository,
-so this guide is mostly pointers to the relevant resources there!
-
-> Happy exception: FiftyOne Brain is strictly Python 3 code, so we do not
-> follow the Python 2 compability instructions in ETA!
-
-## Coding style
-
--   [Python style guide](https://github.com/voxel51/eta/blob/develop/docs/python_style_guide.md)
--   [Linting guide](https://github.com/voxel51/eta/blob/develop/docs/linting_guide.md)
--   [Logging guide](https://github.com/voxel51/eta/blob/develop/docs/logging_guide.md)
--   [Markdown style guide](https://github.com/voxel51/eta/blob/develop/docs/markdown_style_guide.md)
-
-## Documentation
-
-All private or internal-facing documentation in the **public namespace** of the
-`fiftyone.brain` package (i.e., `fiftyone.brain.*`) should be kept out of
-docstrings, as these are visible to end-users of the package. Instead, this
-information can be placed in `#`-style comments or additional triple-quoted
-string literals:
-
-```python
-def func():
-    """Public-facing docs."""
-    # A short private note
-    ...
-
-
-def func2():
-    """Public-facing docs."""
-    """
-    Longer, even more private information.
-    """
-    ...
-```
-
-Be sure to keep a line break between the end of a docstring and the beginning
-of another string literal to prevent them from being
-[concatenated](https://docs.python.org/3/reference/lexical_analysis.html#string-literal-concatenation).
-
-## Exposure of methods in top-level brain package
-
-The `fiftyone.brain` package should expose all core user-functionality at the
-base level. For example, for hardness, the user should be able to execute calls
-in the following way:
-
-```py
-# Users should be able to do this
-import fiftyone.brain as fob
-...
-fob.compute_hardness(...)
-
-# And not have to do this
-import fiftyone.brain.hardness as fobh
-...
-fobh.compute_hardness(...)
-```
-
-So, in `fiftyone/brain/__init__.py`, you should import core, public-facing
-methods:
-
-```py
-from .hardness import compute_hardness
-```
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/experiments/labelerror/README.md b/experiments/labelerror/README.md
deleted file mode 100644
index 97e498d4..00000000
--- a/experiments/labelerror/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# FiftyOne Brain -- Experiment:  Label Error
-
-How can we demonstrate that fiftyone can be used to detect annotation mistakes (or rank the potential that specific labels are wrong).
-
-Problem: Classification
-
-This code does not explicitly use eta or theta because it is intended as something that could be shared for a simple use of fiftyone without learning anything else about Voxel51
-
-
-
-
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br>
-voxel51.com
diff --git a/experiments/labelerror/plot_run_scalar.py b/experiments/labelerror/plot_run_scalar.py
deleted file mode 100644
index 81a337f6..00000000
--- a/experiments/labelerror/plot_run_scalar.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""
-Script to plot a scalar value that was computed as certain points in an
-iterative process.  It will average results from many such runs and plot shaded
-error bars
-
-It can also plot data-files that have multiple fields of results and select one
-of them to plot per file.
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-
-import argparse
-import json
-import os
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-
-class Config:
-    def __init__(self, d):
-        '''d is a dictionary of arguments from command-line.'''
-        self.colors = list(plt.get_cmap('Set1').colors)
-        self.colors_N = len(self.colors)
-        self.error_alpha = 0.2
-
-        self.field = d['field']
-        self.title = d['title']
-        self.xlabel = d['xlabel']
-        self.ylabel = d['ylabel']
-
-    def color(self, index):
-        '''Retrieve the color tuple for index with wrap-around.'''
-        return self.colors[index % self.colors_N]
-
-
-def read_to_array(config, json_path):
-    '''Read the data in the file at json_path into a numpy array.
-
-    The rows in the array are the experiment results and the columns are the
-    values.  Returns a tuple with the data, indices where indices is a
-    vector of the sample locations for the columns.
-    '''
-    with open(json_path, "rt") as f:
-        d = json.load(f)
-
-    num_rows = len(d.keys())
-
-    assert num_rows >= 0
-
-    indices = np.asarray(np.sort(list(d[next(iter(d.keys()))].keys())))
-    ind_lut = {}
-    for i, ind in enumerate(indices):
-        ind_lut[ind] = i
-
-    num_cols = len(indices)
-
-    data = np.zeros((num_rows, num_cols))
-
-    for i, (k, v) in enumerate(d.items()):
-        # v is itself a dictionary with the keys being the indices and the
-        # values being the actual graph values
-        ind_check = np.sort(list(v.keys()))
-        assert np.array_equal(ind_check, indices)
-
-        if not isinstance(v[indices[0]], dict):
-            # build the values this way in case they were not ordered in the dict
-            for j, index in enumerate(indices):
-                data[i][ind_lut[index]] = v[index]
-        else:
-            if not config.field:
-                print(f'fail: data file has dicts but no field specified')
-            for j, index in enumerate(indices):
-                data[i][ind_lut[index]] = v[index][config.field]
-
-    return indices, data
-
-
-def main_plot(config, list_of_inputs):
-    '''Main driver for the graphing, each item in the list_of_inputs is a path
-    to a json file with a set of runs to plot (and, optionally a name).
-
-    name is specified in one of two ways:
-    1.  If the command-line arg has just the json file: "./foo.json", then we
-    will derive the name as "foo".
-    2.  Using a colon: foobar:./foo.json and the name here is "foobar".
-    '''
-
-    fig, ax = plt.subplots()
-
-    for i, input in enumerate(list_of_inputs):
-        print(f"Working on {os.path.basename(input)}")
-
-        # check if input has a colon and if so, split on it
-        if ":" in input:
-            name, json = input.split(":")
-        else:
-            json = input
-            name = os.path.splitext(os.path.basename(input))[0]
-
-        indices, data = read_to_array(config, json)
-
-        mean = data.mean(0)
-        std = data.std(0)
-
-        line, =  ax.plot(indices, mean, color=config.color(i))
-        ax.fill_between(indices, mean - std, mean + std, color=config.color(i),
-                        alpha=config.error_alpha)
-
-        line.set_label(name)
-
-    plt.title(config.title)
-    plt.xlabel(config.xlabel)
-    plt.ylabel(config.ylabel)
-    plt.legend()
-    plt.show()
-
-
-if __name__ == "__main__":
-    parser = argparse.ArgumentParser(prog="plot_run_scalar", add_help=True)
-
-    parser.add_argument("inputs", nargs="+",
-                        help="the input json files with one per curve")
-    parser.add_argument("--field", "-f", type=str, default=None,
-                        help="if data is a dictionary, use this key to get values for plotting")
-    parser.add_argument("--xlabel", "-x", type=str, default=None,
-                        help="string to use as the label of the x-axis.")
-    parser.add_argument("--ylabel", "-y", type=str, default=None,
-                        help="string to use as the label of the y-axis.")
-    parser.add_argument("--title", "-t", type=str, default=None,
-                        help="string to use as the title.")
-
-    args = parser.parse_args()
-
-    config = Config(args.__dict__)
-    main_plot(config, args.inputs)
diff --git a/experiments/labelerror/resnet_cifar10/labelerror.py b/experiments/labelerror/resnet_cifar10/labelerror.py
deleted file mode 100644
index 46070f4d..00000000
--- a/experiments/labelerror/resnet_cifar10/labelerror.py
+++ /dev/null
@@ -1,345 +0,0 @@
-"""
-This trains a cifar-10 under noisy labels with fixes via fiftyone.
-TODO: THIS IS NOT YET MAPPED TO FIFTYONE
-
-After loading the data, a fraction of the annotations will be corrupted at
-random.  Then, the training loop will ensue; after every training (full), a
-fraction of the samples will be evaluated for erroneous annotations and fixed
-(by using the original, correct ones)
-
-What is the comparison?
-To allow for a comparison, only half of the initial data will be used; at
-each iteration through the main loop, a fraction of this data will be added.
-For the annotation error case, we will fix the annotations and add the new
-data.  For the baseline, we will just add the new data, which is what a naive
-data scientist would do anyway.
-
-TODO Another comparison would be to select just random samples to check and fix.
-
-In addition, simple reporting on the accuracy of the identification of errors
-in the annotations
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import json
-import random
-import sys
-import time
-
-from scipy.stats import entropy
-
-from config import *
-from datasets import *
-from simple_resnet import *
-from utils import Timer
-
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def main(config):
-
-    ## Initial Data Input
-    # Productes train_set and valid_set that are lists of tuples: (image, label)
-    whole_dataset = cifar10(root=DATA_DIR)
-    timer = Timer()
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    whole_train_set = list(
-        zip(
-            *preprocess(
-                whole_dataset["train"], [partial(pad, border=4)] + transforms
-            ).values()
-        )
-    )
-    print(f"Finished in {timer():.2} seconds")
-    print("Preprocessing test data")
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished in {timer():.2} seconds")
-
-    # function of dataset
-    N_labels = 10
-
-    # set up the variables for training the model in each increment of the dataset size
-    lr_schedule = PiecewiseLinear([0, 5, config.epochs], [0, 0.4, 0])
-    train_transforms = [Crop(32, 32), FlipLR(), Cutout(8, 8)]
-
-    # compute the derived parameters for the trial based on the dataset and the
-    # provided config.
-    total_N = len(whole_train_set)
-
-    # should be cleaner
-    if config.n_max < 0:
-        config.n_max = total_N
-
-    start_N = round(config.p_initial * total_N)
-
-    incr_N = round((config.n_max - start_N) / config.n_increases)
-
-    corrupt_N = round(config.p_corrupt * total_N)
-
-    print(f"Setting up the experiment: {total_N} training samples.")
-    print(f"- starting with {start_N}")
-    print(f"- incrementing by {incr_N} for {config.n_increases} rounds")
-
-    total_stats = {}
-
-    for run_index in range(config.runs):
-
-        print(f"Starting training iteration {run_index} at {localtime()}")
-
-        # first, corrupt a random percentage of the data
-        # this is wasteful and will create a copy of the training data in memory
-        corr_train_set = whole_train_set.copy()
-
-        # the strategy for corrupting the data randomly and then recalling the
-        # original values
-        corr_bool = [True for x in range(corrupt_N)]
-        corr_bool.extend([False for x in range(total_N - corrupt_N)])
-
-        for i in range(corrupt_N):
-            t = corr_train_set[i]
-            c = random.randint(0, N_labels - 1)
-            while c == t[1]:
-                c = random.randint(0, N_labels - 1)
-            corr_train_set[i] = (t[0], c)
-
-        shuffler = list(range(total_N))
-        random.shuffle(shuffler)
-
-        # common randomization of lists by way of an index permutation (shuffler)
-        # original: a
-        # shuffled: s
-        # shuffler on indices: shuffler
-        # s = [a[x] for x in shuffler]
-        # supports --> s[i] = a[shuffler[i]]
-        corr_train_set = [corr_train_set[x] for x in shuffler]
-        corr_bool = [corr_bool[x] for x in shuffler]
-
-        inuse_N = start_N
-
-        model = Network(simple_resnet()).to(device).half()
-        logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-        valid_batches = DataLoader(
-            valid_set, config.batch_size, shuffle=False, drop_last=False
-        )
-
-        # initially randomly shuffle the dataset and take the initial number of samples
-        corr_train_set_use = corr_train_set[0:inuse_N]
-        corr_train_set_avail = corr_train_set[inuse_N:]
-        print(
-            f"Split training set into two; using {len(corr_train_set_use)}, available {len(corr_train_set_avail)}"
-        )
-
-        sm = torch.nn.Softmax(dim=1)
-
-        stats = {}
-
-        for iteration in range(config.n_increases):
-            print(f"beginning next round of training, using {inuse_N} samples")
-
-            if config.cold_start:
-                model = Network(simple_resnet()).to(device).half()
-                logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-            train_batches = DataLoader(
-                Transform(corr_train_set_use, train_transforms),
-                config.batch_size,
-                shuffle=True,
-                set_random_choices=True,
-                drop_last=True,
-            )
-            lr = (
-                lambda step: lr_schedule(step / len(train_batches))
-                / config.batch_size
-            )
-            opts = [
-                SGD(
-                    trainable_params(model).values(),
-                    {
-                        "lr": lr,
-                        "weight_decay": Const(5e-4 * config.batch_size),
-                        "momentum": Const(0.9),
-                    },
-                )
-            ]
-            state[OPTS] = opts
-
-            for epoch in range(config.epochs):
-                logs.append(
-                    union(
-                        {"epoch": epoch + 1},
-                        train_epoch(
-                            state,
-                            Timer(torch.cuda.synchronize),
-                            train_batches,
-                            valid_batches,
-                        ),
-                    )
-                )
-            logs.df().query(f"epoch=={config.epochs}")[
-                ["train_acc", "valid_acc"]
-            ].describe()
-
-            model.train(False)  # == model.eval()
-
-            # record scores for this iteration
-            iteration_stats = {}
-            iteration_stats["in_use"] = inuse_N
-
-            correct = 0
-            total = 0
-            class_correct = list(0.0 for i in range(10))
-            class_total = list(0.0 for i in range(10))
-            with torch.no_grad():
-                for data in valid_batches.dataloader:
-                    images, labels = data
-                    inputs = dict(input=images.cuda().half())
-                    outputs = model(inputs)
-                    y = outputs["logits"]
-                    _, predicted = torch.max(y, 1)
-                    total += labels.size(0)
-                    labels_gpu = labels.cuda().half()
-                    correct += (predicted == labels_gpu).sum().item()
-                    c = (predicted == labels_gpu).squeeze()
-                    for i in range(min(config.batch_size, len(labels))):
-                        label = labels[i]
-                        class_correct[label] += c[i].item()
-                        class_total[label] += 1
-
-            iteration_stats["validation_accuracy"] = correct / total
-
-            # how many can we fix?
-            fix_N = round(inuse_N * config.p_fixable)
-
-            if config.perfect_fixes:
-                # implements an upper-bound-like stat, that only selects incorrect samples
-                # assumes `config.make_fixes = True`
-                fixed = 0
-
-                for fix_i, needs_fixing in enumerate(corr_bool[:inuse_N]):
-                    if not needs_fixing:
-                        continue
-                    t = corr_train_set_use[fix_i]
-                    w = whole_train_set[shuffler[fix_i]][1]
-                    corr_train_set_use[fix_i] = (t[0], w)
-                    corr_bool[fix_i] = False
-                    fixed += 1
-                    if fixed >= fix_N:
-                        break
-
-                iteration_stats["fix_correct"] = fixed
-                iteration_stats["fix_percent_of_N"] = fixed / fix_N
-                current_corrupted = sum(corr_bool[:inuse_N])
-                iteration_stats["current_corrupted"] = current_corrupted
-
-                print(
-                    f'"predicted" {fixed} of {fix_N} proposals to fix correctly'
-                )
-                print(f"{fixed / fix_N}")
-                print(
-                    f"there are currently a total of {current_corrupted} corrupted labels in use"
-                )
-            else:
-                review_batches = DataLoader(
-                    corr_train_set_use, 1, shuffle=False, drop_last=False
-                )
-                scores = []
-                with torch.no_grad():
-                    for data in review_batches.dataloader:
-                        images, labels = data
-                        inputs = dict(input=images.cuda().half())
-                        outputs = model(inputs)
-                        y = outputs["logits"]
-                        # _, predicted = torch.max(y, 1)
-
-                        # likelihood of error in annotation can be a number of
-                        # different heuristics.  just use entropy for now to get the
-                        # whole script going
-
-                        # todo: implement an entropy in torch to work on gpu
-                        z = sm(y).cpu().numpy().squeeze()
-                        # e  = entropy(sm(y).numpy())
-                        e = entropy(z)
-                        scores.append(e)
-
-                scores = np.asarray(scores)
-                si = np.argsort(scores)
-                si = np.flip(si.squeeze())  # because higher entropy at front
-                # beware of a need to sort in other
-                # heuristics
-
-                # how many of those that we want to fix need to be fixed
-                # this is a bit of a funky calculation because we may want to fix more
-                # than we need to if we've already fixed some --> so, just count the
-                # true positives for now and neglect any false positives
-                fix_correct = 0
-                for fix_i in si[:fix_N]:
-                    if corr_bool[fix_i]:
-                        fix_correct += 1
-
-                iteration_stats["fix_correct"] = fix_correct
-                iteration_stats["fix_percent_of_N"] = fix_correct / fix_N
-
-                current_corrupted = sum(corr_bool[:inuse_N])
-                iteration_stats["current_corrupted"] = current_corrupted
-
-                print(
-                    f"predicted {fix_correct} of {fix_N} proposals to fix correctly"
-                )
-                print(f"{fix_correct / fix_N}")
-                print(
-                    f"there are currently a total of {current_corrupted} corrupted labels in use"
-                )
-
-                if config.make_fixes:
-                    print(f"making the fixes")
-                    # fix the corruptions
-                    for fix_i in si[:fix_N]:
-                        t = corr_train_set_use[fix_i]
-                        w = whole_train_set[shuffler[fix_i]][1]
-                        corr_train_set_use[fix_i] = (t[0], w)
-                        corr_bool[fix_i] = False
-                else:
-                    print(f"not making the fixes because of the config")
-
-            model.train(True)
-
-            # extend the corr_train_set_use with that from avail
-            corr_train_set_use.extend(corr_train_set_avail[0:incr_N])
-            corr_train_set_avail = corr_train_set_avail[incr_N:]
-            inuse_N += incr_N
-            assert inuse_N == len(corr_train_set_use)
-
-            stats[inuse_N] = iteration_stats
-
-        total_stats[run_index] = stats
-
-    print(f"finished the full training; stats to follow")
-    print(total_stats)
-
-    if config.stats_path:
-        with open(config.stats_path, "w") as fp:
-            json.dump(total_stats, fp)
-
-
-if __name__ == "__main__":
-
-    config = commandline()
-
-    print(f"running with config: {config}")
-
-    main(config)
diff --git a/experiments/labelerror/resnet_cifar10/simple_resnet.py b/experiments/labelerror/resnet_cifar10/simple_resnet.py
deleted file mode 100644
index 82c274f1..00000000
--- a/experiments/labelerror/resnet_cifar10/simple_resnet.py
+++ /dev/null
@@ -1,571 +0,0 @@
-"""
-Definition and Implementation of a Simple Resnet and Supporting Functions
-
-Original Implementation of this is from David Page's work on fast model
-training with resnets.  <https://github.com/davidcpage/cifar10-fast/>
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from collections import namedtuple, defaultdict
-import copy
-from functools import partial, singledispatch
-from itertools import chain
-
-import numpy as np
-import torch
-from torch import nn
-
-from utils import *
-
-
-torch.backends.cudnn.benchmark = True
-
-
-## Data Preprocessing and Handling
-def preprocess(dataset, transforms):
-    dataset = copy.copy(dataset)  # shallow copy
-    for transform in transforms:
-        dataset["data"] = transform(dataset["data"])
-    return dataset
-
-
-@singledispatch
-def normalise(x, mean, std):
-    return (x - mean) / std
-
-
-@normalise.register(np.ndarray)
-def _(x, mean, std):
-    # faster inplace for numpy arrays
-    x = np.array(x, np.float32)
-    x -= mean
-    x *= 1.0 / std
-    return x
-
-
-unnormalise = lambda x, mean, std: x * std + mean
-
-
-@singledispatch
-def to_numpy(x):
-    raise NotImplementedError
-
-
-@to_numpy.register(torch.Tensor)
-def _(x):
-    return x.detach().cpu().numpy()
-
-
-@singledispatch
-def pad(x, border):
-    raise NotImplementedError
-
-
-@pad.register(np.ndarray)
-def _(x, border):
-    return np.pad(
-        x, [(0, 0), (border, border), (border, border), (0, 0)], mode="reflect"
-    )
-
-
-@pad.register(torch.Tensor)
-def _(x, border):
-    return nn.ReflectionPad2d(border)(x)
-
-
-@singledispatch
-def transpose(x, source, target):
-    raise NotImplementedError
-
-
-@transpose.register(np.ndarray)
-def _(x, source, target):
-    return x.transpose([source.index(d) for d in target])
-
-
-@transpose.register(torch.Tensor)
-def _(x, source, target):
-    return x.permute([source.index(d) for d in target])
-
-
-class Crop(namedtuple("Crop", ("h", "w"))):
-    def __call__(self, x, x0, y0):
-        return x[..., y0 : y0 + self.h, x0 : x0 + self.w]
-
-    def options(self, shape):
-        *_, H, W = shape
-        return [
-            {"x0": x0, "y0": y0}
-            for x0 in range(W + 1 - self.w)
-            for y0 in range(H + 1 - self.h)
-        ]
-
-    def output_shape(self, shape):
-        *_, H, W = shape
-        return (*_, self.h, self.w)
-
-
-@singledispatch
-def flip_lr(x):
-    raise NotImplementedError
-
-
-@flip_lr.register(np.ndarray)
-def _(x):
-    return x[..., ::-1].copy()
-
-
-@flip_lr.register(torch.Tensor)
-def _(x):
-    return torch.flip(x, [-1])
-
-
-class FlipLR(namedtuple("FlipLR", ())):
-    def __call__(self, x, choice):
-        return flip_lr(x) if choice else x
-
-    def options(self, shape):
-        return [{"choice": b} for b in [True, False]]
-
-
-class Cutout(namedtuple("Cutout", ("h", "w"))):
-    def __call__(self, x, x0, y0):
-        x[..., y0 : y0 + self.h, x0 : x0 + self.w] = 0.0
-        return x
-
-    def options(self, shape):
-        *_, H, W = shape
-        return [
-            {"x0": x0, "y0": y0}
-            for x0 in range(W + 1 - self.w)
-            for y0 in range(H + 1 - self.h)
-        ]
-
-
-class Transform:
-    def __init__(self, dataset, transforms):
-        self.dataset, self.transforms = dataset, transforms
-        self.choices = None
-
-    def __len__(self):
-        return len(self.dataset)
-
-    def __getitem__(self, index):
-        data, labels = self.dataset[index]
-        data = data.copy()
-        for choices, f in zip(self.choices, self.transforms):
-            data = f(data, **choices[index])
-        return data, labels
-
-    def set_random_choices(self):
-        self.choices = []
-        x_shape = self.dataset[0][0].shape
-        N = len(self)
-        for t in self.transforms:
-            self.choices.append(np.random.choice(t.options(x_shape), N))
-            x_shape = (
-                t.output_shape(x_shape)
-                if hasattr(t, "output_shape")
-                else x_shape
-            )
-
-
-## Utils
-
-
-class PiecewiseLinear(namedtuple("PiecewiseLinear", ("knots", "vals"))):
-    def __call__(self, t):
-        return np.interp([t], self.knots, self.vals)[0]
-
-
-class Const(namedtuple("Const", ["val"])):
-    def __call__(self, x):
-        return self.val
-
-
-## Define the network
-
-has_inputs = lambda node: type(node) is tuple
-
-
-def build_graph(net):
-    flattened = pipeline(net)
-    resolve_input = (
-        lambda rel_path, path, idx: normpath(sep.join((path, "..", rel_path)))
-        if isinstance(rel_path, str)
-        else flattened[idx + rel_path][0]
-    )
-    return {
-        path: (
-            node[0],
-            [resolve_input(rel_path, path, idx) for rel_path in node[1]],
-        )
-        for idx, (path, node) in enumerate(flattened)
-    }
-
-
-def pipeline(net):
-    return [
-        (sep.join(path), (node if has_inputs(node) else (node, [-1])))
-        for (path, node) in path_iter(net)
-    ]
-
-
-class Network(nn.Module):
-    def __init__(self, net):
-        super().__init__()
-        self.graph = build_graph(net)
-        for path, (val, _) in self.graph.items():
-            setattr(self, path.replace("/", "_"), val)
-
-    def nodes(self):
-        return (node for node, _ in self.graph.values())
-
-    def forward(self, inputs):
-        outputs = dict(inputs)
-        for k, (node, ins) in self.graph.items():
-            # only compute nodes that are not supplied as inputs.
-            if k not in outputs:
-                outputs[k] = node(*[outputs[x] for x in ins])
-        return outputs
-
-    def half(self):
-        for node in self.nodes():
-            if isinstance(node, nn.Module) and not isinstance(
-                node, nn.BatchNorm2d
-            ):
-                node.half()
-        return self
-
-
-class Identity(namedtuple("Identity", [])):
-    def __call__(self, x):
-        return x
-
-
-class Add(namedtuple("Add", [])):
-    def __call__(self, x, y):
-        return x + y
-
-
-class AddWeighted(namedtuple("AddWeighted", ["wx", "wy"])):
-    def __call__(self, x, y):
-        return self.wx * x + self.wy * y
-
-
-class Mul(nn.Module):
-    def __init__(self, weight):
-        super().__init__()
-        self.weight = weight
-
-    def __call__(self, x):
-        return x * self.weight
-
-
-class Flatten(nn.Module):
-    def forward(self, x):
-        return x.view(x.size(0), x.size(1))
-
-
-class Concat(nn.Module):
-    def forward(self, *xs):
-        return torch.cat(xs, 1)
-
-
-class BatchNorm(nn.BatchNorm2d):
-    def __init__(
-        self,
-        num_features,
-        eps=1e-05,
-        momentum=0.1,
-        weight_freeze=False,
-        bias_freeze=False,
-        weight_init=1.0,
-        bias_init=0.0,
-    ):
-        super().__init__(num_features, eps=eps, momentum=momentum)
-        if weight_init is not None:
-            self.weight.data.fill_(weight_init)
-        if bias_init is not None:
-            self.bias.data.fill_(bias_init)
-        self.weight.requires_grad = not weight_freeze
-        self.bias.requires_grad = not bias_freeze
-
-
-def conv_bn(c_in, c_out):
-    return {
-        "conv": nn.Conv2d(
-            c_in, c_out, kernel_size=3, stride=1, padding=1, bias=False
-        ),
-        "bn": BatchNorm(c_out),
-        "relu": nn.ReLU(True),
-    }
-
-
-def residual(c):
-    return {
-        "in": Identity(),
-        "res1": conv_bn(c, c),
-        "res2": conv_bn(c, c),
-        "add": (Add(), ["in", "res2/relu"]),
-    }
-
-
-def simple_resnet(
-    channels=None,
-    weight=0.125,
-    pool=nn.MaxPool2d(2),
-    extra_layers=(),
-    res_layers=("layer1", "layer3"),
-):
-    channels = channels or {
-        "prep": 64,
-        "layer1": 128,
-        "layer2": 256,
-        "layer3": 512,
-    }
-    n = {
-        "input": (None, []),
-        "prep": conv_bn(3, channels["prep"]),
-        "layer1": dict(
-            conv_bn(channels["prep"], channels["layer1"]), pool=pool
-        ),
-        "layer2": dict(
-            conv_bn(channels["layer1"], channels["layer2"]), pool=pool
-        ),
-        "layer3": dict(
-            conv_bn(channels["layer2"], channels["layer3"]), pool=pool
-        ),
-        "pool": nn.MaxPool2d(4),
-        "flatten": Flatten(),
-        "linear": nn.Linear(channels["layer3"], 10, bias=False),
-        "logits": Mul(weight),
-    }
-    for layer in res_layers:
-        n[layer]["residual"] = residual(channels[layer])
-    for layer in extra_layers:
-        n[layer]["extra"] = conv_bn(channels[layer], channels[layer])
-    return n
-
-
-## Losses, Optimizers
-
-
-class CrossEntropyLoss(namedtuple("CrossEntropyLoss", [])):
-    def __call__(self, log_probs, target):
-        return torch.nn.functional.nll_loss(
-            log_probs, target, reduction="none"
-        )
-
-
-class KLLoss(namedtuple("KLLoss", [])):
-    def __call__(self, log_probs):
-        return -log_probs.mean(dim=1)
-
-
-class Correct(namedtuple("Correct", [])):
-    def __call__(self, classifier, target):
-        return classifier.max(dim=1)[1] == target
-
-
-class LogSoftmax(namedtuple("LogSoftmax", ["dim"])):
-    def __call__(self, x):
-        return torch.nn.functional.log_softmax(x, self.dim, _stacklevel=5)
-
-
-x_ent_loss = Network(
-    {
-        "loss": (nn.CrossEntropyLoss(reduction="none"), ["logits", "target"]),
-        "acc": (Correct(), ["logits", "target"]),
-    }
-)
-
-label_smoothing_loss = lambda alpha: Network(
-    {
-        "logprobs": (LogSoftmax(dim=1), ["logits"]),
-        "KL": (KLLoss(), ["logprobs"]),
-        "xent": (CrossEntropyLoss(), ["logprobs", "target"]),
-        "loss": (AddWeighted(wx=1 - alpha, wy=alpha), ["xent", "KL"]),
-        "acc": (Correct(), ["logits", "target"]),
-    }
-)
-
-trainable_params = lambda model: {
-    k: p for k, p in model.named_parameters() if p.requires_grad
-}
-
-
-def nesterov_update(w, dw, v, lr, weight_decay, momentum):
-    dw.add_(weight_decay, w).mul_(-lr)
-    v.mul_(momentum).add_(dw)
-    w.add_(dw.add_(momentum, v))
-
-
-norm = lambda x: torch.norm(x.reshape(x.size(0), -1).float(), dim=1)[
-    :, None, None, None
-]
-
-
-def LARS_update(w, dw, v, lr, weight_decay, momentum):
-    nesterov_update(
-        w,
-        dw,
-        v,
-        lr * (norm(w) / (norm(dw) + 1e-2)).to(w.dtype),
-        weight_decay,
-        momentum,
-    )
-
-
-def zeros_like(weights):
-    return [torch.zeros_like(w) for w in weights]
-
-
-def optimiser(weights, param_schedule, update, state_init):
-    weights = list(weights)
-    return {
-        "update": update,
-        "param_schedule": param_schedule,
-        "step_number": 0,
-        "weights": weights,
-        "opt_state": state_init(weights),
-    }
-
-
-def opt_step(update, param_schedule, step_number, weights, opt_state):
-    step_number += 1
-    param_values = {k: f(step_number) for k, f in param_schedule.items()}
-    for w, v in zip(weights, opt_state):
-        if w.requires_grad:
-            update(w.data, w.grad.data, v, **param_values)
-    return {
-        "update": update,
-        "param_schedule": param_schedule,
-        "step_number": step_number,
-        "weights": weights,
-        "opt_state": opt_state,
-    }
-
-
-LARS = partial(optimiser, update=LARS_update, state_init=zeros_like)
-SGD = partial(optimiser, update=nesterov_update, state_init=zeros_like)
-
-
-## Training Code
-
-MODEL = "model"
-LOSS = "loss"
-VALID_MODEL = "valid_model"
-OPTS = "optimisers"
-OUTPUT = "output"
-ACT_LOG = "activation_log"
-
-
-def reduce(batches, state, steps):
-    # state: is a dictionary
-    # steps: are functions that take (batch, state)
-    # and return a dictionary of updates to the state (or None)
-
-    for batch in chain(batches, [None]):
-        # we send an extra batch=None at the end for steps that
-        # need to do some tidying-up (e.g. log_activations)
-        for step in steps:
-            updates = step(batch, state)
-            if updates:
-                for k, v in updates.items():
-                    state[k] = v
-    return state
-
-
-def forward(training_mode):
-    def step(batch, state):
-        if not batch:
-            return
-        model = (
-            state[MODEL]
-            if training_mode or (VALID_MODEL not in state)
-            else state[VALID_MODEL]
-        )
-        if model.training != training_mode:  # without the guard it's slow!
-            model.train(training_mode)
-        return {OUTPUT: state[LOSS](model(batch))}
-
-    return step
-
-
-def backward(dtype=None):
-    def step(batch, state):
-        state[MODEL].zero_grad()
-        if not batch:
-            return
-        loss = state[OUTPUT][LOSS]
-        if dtype is not None:
-            loss = loss.to(dtype)
-        loss.sum().backward()
-
-    return step
-
-
-def opt_steps(batch, state):
-    if not batch:
-        return
-    return {OPTS: [opt_step(**opt) for opt in state[OPTS]]}
-
-
-def log_activations(node_names=("loss", "acc")):
-    def step(batch, state):
-        if "_tmp_logs_" not in state:
-            state["_tmp_logs_"] = []
-        if batch:
-            state["_tmp_logs_"].extend(
-                (k, state[OUTPUT][k].detach()) for k in node_names
-            )
-        else:
-            res = {
-                k: to_numpy(torch.cat(xs)).astype(np.float)
-                for k, xs in group_by_key(state["_tmp_logs_"]).items()
-            }
-            del state["_tmp_logs_"]
-            return {ACT_LOG: res}
-
-    return step
-
-
-epoch_stats = lambda state: {k: np.mean(v) for k, v in state[ACT_LOG].items()}
-
-default_train_steps = (
-    forward(training_mode=True),
-    log_activations(("loss", "acc")),
-    backward(),
-    opt_steps,
-)
-default_valid_steps = (
-    forward(training_mode=False),
-    log_activations(("loss", "acc")),
-)
-
-
-def train_epoch(
-    state,
-    timer,
-    train_batches,
-    valid_batches,
-    train_steps=default_train_steps,
-    valid_steps=default_valid_steps,
-    on_epoch_end=(lambda state: state),
-):
-    train_summary, train_time = (
-        epoch_stats(on_epoch_end(reduce(train_batches, state, train_steps))),
-        timer(),
-    )
-    valid_summary, valid_time = (
-        epoch_stats(reduce(valid_batches, state, valid_steps)),
-        timer(include_in_total=False),
-    )  # DAWNBench rules
-    return {
-        "train": union({"time": train_time}, train_summary),
-        "valid": union({"time": valid_time}, valid_summary),
-        "total time": timer.total_time,
-    }
diff --git a/experiments/uniqueness/README.md b/experiments/uniqueness/README.md
deleted file mode 100644
index db94f8f2..00000000
--- a/experiments/uniqueness/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# FiftyOne Brain -- Experiment: Uniqueness
-
-How can we demonstrate that fiftyone can be used to rank samples in an
-otherwise unlabeled (or labeled) dataset by their "uniqueness"? This directory
-houses experiments to answer this and related questions.
-
-Problem: Unlabeled
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/fiftyone/brain/__init__.py b/fiftyone/brain/__init__.py
index 7e3b52f5..d0187396 100644
--- a/fiftyone/brain/__init__.py
+++ b/fiftyone/brain/__init__.py
@@ -235,9 +235,9 @@ def compute_uniqueness(
             before extracting them, in ``[-1, inf)``. If provided, the length
             and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-            ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
-            to contract the boxes by 10%. Only applicable when a ``model`` and
-            ``roi_field`` are specified
+            ``alpha = 0.1`` to expand the boxes by 10%, and set
+            ``alpha = -0.1`` to contract the boxes by 10%. Only applicable when
+            a ``model`` and ``roi_field`` are specified
         batch_size (None): a batch size to use when computing embeddings. Only
             applicable when a ``model`` is provided
         num_workers (None): the number of workers to use when loading images.
@@ -339,9 +339,9 @@ def compute_representativeness(
             before extracting them, in ``[-1, inf)``. If provided, the length
             and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-            ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
-            to contract the boxes by 10%. Only applicable when a ``model`` and
-            ``roi_field`` are specified
+            ``alpha = 0.1`` to expand the boxes by 10%, and set
+            ``alpha = -0.1`` to contract the boxes by 10%. Only applicable when
+            a ``model`` and ``roi_field`` are specified
         batch_size (None): a batch size to use when computing embeddings. Only
             applicable when a ``model`` is provided
         num_workers (None): the number of workers to use when loading images.
@@ -485,9 +485,9 @@ def compute_visualization(
             before extracting them, in ``[-1, inf)``. If provided, the length
             and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-            ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
-            to contract the boxes by 10%. Only applicable when a ``model`` and
-            ``patches_field`` are specified
+            ``alpha = 0.1`` to expand the boxes by 10%, and set
+            ``alpha = -0.1`` to contract the boxes by 10%. Only applicable when
+            a ``model`` and ``patches_field`` are specified
         batch_size (None): an optional batch size to use when computing
             embeddings. Only applicable when a ``model`` is provided
         num_workers (None): the number of workers to use when loading images.
@@ -626,9 +626,9 @@ def compute_similarity(
             before extracting them, in ``[-1, inf)``. If provided, the length
             and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-            ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
-            to contract the boxes by 10%. Only applicable when a ``model`` and
-            ``patches_field`` are specified
+            ``alpha = 0.1`` to expand the boxes by 10%, and set
+            ``alpha = -0.1`` to contract the boxes by 10%. Only applicable when
+            a ``model`` and ``patches_field`` are specified
         batch_size (None): an optional batch size to use when computing
             embeddings. Only applicable when a ``model`` is provided
         num_workers (None): the number of workers to use when loading images.
diff --git a/fiftyone/brain/internal/core/mongodb.py b/fiftyone/brain/internal/core/mongodb.py
index e1ea3d20..88e053b6 100644
--- a/fiftyone/brain/internal/core/mongodb.py
+++ b/fiftyone/brain/internal/core/mongodb.py
@@ -126,10 +126,10 @@ def ensure_requirements(self):
         # eg Atlas clusters generally have hostnames which end in "mongodb.net"
         # https://stackoverflow.com/q/73180110
         #
-        fou.ensure_package("pymongo>=4.5")
+        fou.ensure_package("pymongo>=4.5,<4.9")
 
     def ensure_usage_requirements(self):
-        fou.ensure_package("pymongo>=4.5")
+        fou.ensure_package("pymongo>=4.5,<4.9")
 
     def initialize(self, samples, brain_key):
         return MongoDBSimilarityIndex(
diff --git a/fiftyone/brain/internal/core/pinecone.py b/fiftyone/brain/internal/core/pinecone.py
index bc1818be..4ee05f46 100644
--- a/fiftyone/brain/internal/core/pinecone.py
+++ b/fiftyone/brain/internal/core/pinecone.py
@@ -522,7 +522,7 @@ def _kneighbors(
             )
 
         if k is None or k > self.config.max_k:
-            raise ValueError("Pincone requires k<=%s" % self.config.max_k)
+            raise ValueError("Pinecone requires k<=%s" % self.config.max_k)
 
         if aggregation not in (None, "mean"):
             raise ValueError("Unsupported aggregation '%s'" % aggregation)
diff --git a/fiftyone/brain/similarity.py b/fiftyone/brain/similarity.py
index 975b855e..f367f972 100644
--- a/fiftyone/brain/similarity.py
+++ b/fiftyone/brain/similarity.py
@@ -902,8 +902,8 @@ def compute_embeddings(
                 patches before extracting them, in ``[-1, inf)``. If provided,
                 the length and width of the box are expanded (or contracted,
                 when ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-                ``alpha = 1.1`` to expand the boxes by 10%, and set
-                ``alpha = 0.9`` to contract the boxes by 10%. Only applicable
+                ``alpha = 0.1`` to expand the boxes by 10%, and set
+                ``alpha = -0.1`` to contract the boxes by 10%. Only applicable
                 when a ``model`` and ``patches_field`` are specified
             progress (None): whether to render a progress bar (True/False), use
                 the default value ``fiftyone.config.show_progress_bars``
diff --git a/install.bash b/install.bash
index cd980766..bf736dfe 100644
--- a/install.bash
+++ b/install.bash
@@ -39,9 +39,10 @@ if [ ${DEV_INSTALL} = true ]; then
     echo "Performing dev install"
     pip install -r requirements/dev.txt
     pre-commit install
+    pip install -e .
 else
     pip install -r requirements.txt
+    pip install .
 fi
-pip install -e .
 
 echo "***** INSTALLATION COMPLETE *****"
diff --git a/install.bat b/install.bat
new file mode 100644
index 00000000..4242372b
--- /dev/null
+++ b/install.bat
@@ -0,0 +1,43 @@
+@echo off
+:: Installs the `fiftyone-brain` package and its dependencies.
+::
+:: Usage:
+:: .\install.bat
+::
+:: Copyright 2017-2024, Voxel51, Inc.
+:: voxel51.com
+::
+:: Commands:
+:: -h      Display help message
+:: -d      Install developer dependencies.
+
+set SHOW_HELP=false
+set DEV_INSTALL=false
+
+:parse
+IF "%~1"=="" GOTO endparse
+IF "%~1"=="-h" GOTO helpmessage
+IF "%~1"=="-d" set DEV_INSTALL=true
+SHIFT
+GOTO parse
+:endparse
+
+echo ***** INSTALLING FIFTYONE-BRAIN *****
+IF %DEV_INSTALL%==true (
+  echo Performing dev install
+  pip install -r requirements/dev.txt
+  pre-commit install
+  pip install -e .
+) else (
+  pip install -r requirements.txt
+  pip install .
+)
+
+echo ***** INSTALLATION COMPLETE *****
+exit /b
+
+:helpmessage
+echo Additional Arguments:
+echo -h      Display help message
+echo -d      Install developer dependencies.
+exit /b
\ No newline at end of file
diff --git a/production/README.md b/production/README.md
deleted file mode 100644
index 2d07f662..00000000
--- a/production/README.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# FiftyOne Brain: Production
-
-The tools needed for any backend production-oriented work in the brains behind
-[FiftyOne](https://github.com/voxel51/fiftyone).
-
-<img src="https://user-images.githubusercontent.com/3719547/74191434-8fe4f500-4c21-11ea-8d73-555edfce0854.png" alt="voxel51-logo.png" width="40%"/>
-
-## Organization
-
--   `models/` code related to training/developing production models
--   `marketing/` scripts to generate reproducible marketing material
-
-## Production models
-
-The `models/` folder tracks the production models used by the FiftyOne Brain.
-Brain models follow the ETA model development and deployment practices. For
-full documentation,
-[see here](https://github.com/voxel51/eta/blob/develop/docs/models_dev_guide.md).
-
-### Adding a model to the Brain
-
-To add a model to the Brain, following these steps:
-
-1. In `fiftyone/brain/internal/models/cache/manifest.json`, add an entry for
-   the model you want to publish (with info from steps below)
-
-2. Upload the model's data blob to
-   [this folder](https://drive.google.com/drive/u/1/folders/15lu2orhqGocHHgkprcye1gNXrFk2wrW0),
-   turn on public link sharing, and put the file ID in the manifest entry
-
-3. For the `manager.type` field of the manifest you can directly use
-   `eta.core.models.ETAModelManager`. This is a class that knows how to
-   download publicly available files from Google Drive
-
-4. The `default_deployment_config_dict` is optional: it's for when you've
-   implemented an `eta.core.learning.Model` subclass that knows how to load
-   your model
-
-> TODO Add Developer information with some example information.
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/production/marketing/.gitignore b/production/marketing/.gitignore
deleted file mode 100644
index 9fea4c99..00000000
--- a/production/marketing/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-outputs/
diff --git a/production/marketing/README.md b/production/marketing/README.md
deleted file mode 100644
index b18f6232..00000000
--- a/production/marketing/README.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# FiftyOne Brain: Production/Marketing
-
-Tools for generating marketing material across the FiftyOne project.
-
-This provides information on generating repeatable marketing material (graphs,
-screenshots, etc) backed by code.
-
-This is in `fiftyone-brain` instead of `fiftyone` because it may reference
-experiment details with proprietary information that only exists in the brain.
-
-## Contents
-
-| Asset                    | Description                                                              |
-| ------------------------ | ------------------------------------------------------------------------ |
-| `shot_bdd_detections.py` | Snaps a screenshot of some BDD object detections loaded in the dashboard |
-| `shot_neardups.py`       | Snaps a screenshot of the near-duplicates output from CIFAR-10           |
-| `use_cases_code.md`      | Example code demonstrating common FiftyOne use cases                     |
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/production/marketing/shot_bdd_detections.py b/production/marketing/shot_bdd_detections.py
deleted file mode 100644
index c88f1099..00000000
--- a/production/marketing/shot_bdd_detections.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Script to generate a screenshot of some object detections from the BDD dataset.
-
-You need to run this inside of an interactive shell like IPython because the
-system will launch the dashboard and we need to have that process independent
-of the python code.
-
-Outputs are placed in the `outputs/` directory.
-
-Usage::
-
-    # From inside IPython
-    run shot_bdd_detections.py
-
-Copyright 2017-2024, Voxel51, Inc.
-voxel51.com
-"""
-import os
-import time
-
-import eta.core.utils as etau
-import eta.core.storage as etas
-
-import fiftyone as fo
-
-import fiftyone.brain as fob
-
-
-FILE_ID = "1uioZHQ8VVUYb2OIrdD5M5gSw4XmGIOi_"
-
-DATASET_DIR = "data/bdd_subset"
-OUTPUT_DIR = "outputs"
-
-
-print("Working on BDD object detections")
-
-# Download dataset if necessary
-if not os.path.isdir(DATASET_DIR):
-    print("Downloading dataset to '%s'" % DATASET_DIR)
-    client = etas.GoogleDriveStorageClient()
-    tmp_path = client.get_file_metadata(FILE_ID)["name"]
-    client.download(FILE_ID, tmp_path)
-    etau.extract_archive(tmp_path, outdir="data", delete_archive=True)
-
-dataset = fo.Dataset.from_image_detection_dataset(DATASET_DIR, "BDD")
-
-fob.compute_uniqueness(dataset)
-
-view = dataset.view().sort_by("uniqueness", reverse=True)
-
-print("Launching dashboard...")
-session = fo.launch_dashboard(view=view)
-
-input("Press enter when you are ready for the screenshot:")
-
-localtime = lambda: time.strftime("%Y%m%d-%H%M%S", time.localtime())
-outname = "bdd_detections_" + localtime() + ".png"
-outpath = os.path.join(OUTPUT_DIR, outname)
-
-etau.save_window_snapshot("FiftyOne", outpath)
diff --git a/production/marketing/shot_neardups.py b/production/marketing/shot_neardups.py
deleted file mode 100644
index dbcfa941..00000000
--- a/production/marketing/shot_neardups.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-Script to generate a screenshot of near-duplicates using the uniqueness and
-ranking.
-
-You need to run this inside of an interactive shell like IPython because the
-system will launch the dashboard and we need to have that process independent
-of the python code.
-
-Outputs are placed in the `outputs/` directory.
-
-Usage::
-
-    # From inside IPython
-    run shot_neardups.py
-
-Copyright 2017-2024, Voxel51, Inc.
-voxel51.com
-"""
-import os
-import time
-
-import eta.core.utils as etau
-
-import fiftyone as fo
-import fiftyone.zoo as foz
-
-import fiftyone.brain as fob
-
-
-OUTPUT_DIR = "outputs"
-
-
-print("Working on near-duplicates in CIFAR-10 Test")
-
-dataset = foz.load_zoo_dataset("cifar10", split="test")
-
-fob.compute_uniqueness(dataset)
-
-view = dataset.view().sort_by("uniqueness")
-
-print("Launching dashboard...")
-session = fo.launch_dashboard(view=view)
-
-input("Press enter when you are ready for the screenshot:")
-
-localtime = lambda: time.strftime("%Y%m%d-%H%M%S", time.localtime())
-outname = "neardups_cifar10_test" + localtime() + ".png"
-outpath = os.path.join(OUTPUT_DIR, outname)
-
-etau.save_window_snapshot("FiftyOne", outpath)
diff --git a/production/marketing/use_cases_code.md b/production/marketing/use_cases_code.md
deleted file mode 100644
index 9a3e7b83..00000000
--- a/production/marketing/use_cases_code.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# FiftyOne Use Cases
-
-FiftyOne's data model is highly customizable. You can create datasets with
-common content such as ground truth annotations, and you can freely add custom
-fields to store additional information such as model predictions, metadata, and
-more.
-
-For example:
-
-```py
-import fiftyone as fo
-
-# Create a basic test sample with ground truth annotation
-sample = fo.Sample(
-    filepath="/path/to/highway.jpg",
-    tags=["test"],
-    ground_truth=fo.Classification(label="highway"),
-)
-
-# Add a model prediction
-sample["my_model"] = fo.Classification(label="highway", confidence=0.957)
-
-# Add other custom metadata
-sample.tags.append("difficult")
-sample["frame_quaility"] = 62.9834
-sample["weather"] = "sunny"
-
-# Add sample to a dataset
-dataset = fo.Dataset("highways")
-dataset.add_sample(sample)
-
-print(dataset.summary())
-print(sample)
-```
-
-produces a dataset with the following content:
-
-```
-Name:           highways
-Num samples:    1
-Tags:           ['test', 'difficult']
-Sample fields:
-    filepath:       fiftyone.core.fields.StringField
-    tags:           fiftyone.core.fields.ListField(fiftyone.core.fields.StringField)
-    metadata:       fiftyone.core.metadata.Metadata
-    ground_truth:   fiftyone.core.labels.Classification
-    my_model:       fiftyone.core.labels.Classification
-    frame_quaility: fiftyone.core.fields.FloatField
-    weather:        fiftyone.core.fields.StringField
-
-{
-    "_id": {
-        "$oid": "5ecdd7f58efd8c18fcea4073"
-    },
-    "filepath": "/path/to/highway.jpg",
-    "tags": [
-        "test",
-        "difficult"
-    ],
-    "metadata": null,
-    "ground_truth": {
-        "_cls": "Classification",
-        "label": "highway",
-        "confidence": null,
-        "logits": null
-    },
-    "my_model": {
-        "_cls": "Classification",
-        "label": "highway",
-        "confidence": 0.957,
-        "logits": null
-    },
-    "frame_quaility": 62.9834,
-    "weather": "sunny"
-}
-```
-
-## Recommending next samples for annotation
-
-FiftyOne provides builtin support for finding the most representative unlabeled
-images in your dataset to send for annotation:
-
-```py
-import fiftyone.brain as fob
-
-unlabeled_view = dataset.view().match_tag("unlabeled")
-
-# Index your dataset by uniqueness
-fob.compute_uniqueness(unlabeled_view)
-
-# Recommend 10 most representative images to annotate
-anno_view = unlabeled_view.sort_by("uniqueness", reverse=True).limit(10)
-
-# Explore the samples visually in the GUI
-fo.launch_dashboard(view=anno_view)
-```
-
-## Finding annotation errors
-
-FiftyOne provides builtin support for finding likely annotation errors in your
-dataset, using your model predictions to automatically inform the analysis:
-
-```py
-import fiftyone.brain as fob
-
-# Index your dataset by likelihood of annotation error
-fob.compute_mistakenness(dataset, pred_field="my_model")
-
-# Get 10 most likely annotation mistakes
-mistakes_view = dataset.view().sort_by("mistakenness", reverse=True).limit(10)
-
-# Explore the samples visually in the GUI
-fo.launch_dashboard(view=mistakes_view)
-```
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/production/models/simple_resnet/.gitignore b/production/models/simple_resnet/.gitignore
deleted file mode 100644
index e7ecb0aa..00000000
--- a/production/models/simple_resnet/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*.pth
-*.pt
diff --git a/production/models/simple_resnet/README.md b/production/models/simple_resnet/README.md
deleted file mode 100644
index 3b8106c1..00000000
--- a/production/models/simple_resnet/README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# FiftyOne Brain: Production/Models/Simple_Resnet
-
-Code for training the production `simple_resnet` model.
-
--   Training scripts
--   Dataset support is only CIFAR-10 now
--   Deployment script and documentation
-
-## CIFAR-10 Classifier
-
-The FiftyOne Brain Model Drive (BMD) is located at
-[Shared drives://models/fiftyone-brain](https://drive.google.com/drive/u/1/folders/15lu2orhqGocHHgkprcye1gNXrFk2wrW0)
-`15lu2orhqGocHHgkprcye1gNXrFk2wrW0`.
-
-The name of the model weights file is `simple_resnet_cifar10.pth`.
-
-Steps to train and deploy the model:
-
-1. Run the `train_classifier_cifar10.bash` script
-
-2. Upload the output `simple_resnet_cifar10.pth` to the BMD
-
-3. Update the entry for the model in models manifest at
-   `fiftyone/brain/internal/models/cache/manifest.json`. Be sure to include any
-   information about the parameters stored there such as `image_mean` if they
-   have changed. This is critical
-
-## Copyright
-
-Copyright 2017-2024, Voxel51, Inc.<br> voxel51.com
diff --git a/production/models/simple_resnet/config.py b/production/models/simple_resnet/config.py
deleted file mode 100644
index b642116a..00000000
--- a/production/models/simple_resnet/config.py
+++ /dev/null
@@ -1,185 +0,0 @@
-"""
-Simple configuration setup for these experiments
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-import argparse
-
-
-DATA_DIR = "/scratch/jason-data-cache/"
-MODEL_DIR = "/scratch/jason-model-cache/"
-
-
-class Config:
-    """Encapsulates the configuration settings for a trial."""
-
-    def __init__(self, d):
-        """d is a dictionary of arguments from command-line."""
-
-        self.batch_size = d["batch_size"]
-        self.cold_start = d["cold_start"]
-        self.epochs = d["epochs"]
-
-        self.fixes = d["fixes"]
-        self.make_fixes = True
-        self.perfect_fixes = False
-        if self.fixes == "no":
-            self.makes_fixes = False
-        if self.fixes == "perfect":
-            self.make_fixes = True
-            self.perfect_fixes = True
-
-        self.model_path = d["model_path"]
-        self.n_rounds = d["num_rounds"]
-        self.n_max = d["num_max_samples"]
-        self.p_corrupt = d["percent_corrupt"]
-        self.p_fixable = d["percent_fixable"]
-        self.p_initial = d["percent_initial"]
-
-        self.runs = d["runs"]
-        self.start_ipython = d["start_ipython"]
-        self.stats_path = d["stats_path"]
-        self.take = d["take"]
-
-    def __str__(self):
-        return str(vars(self))
-
-
-def commandline():
-    parser = argparse.ArgumentParser(add_help=True)
-
-    parser.add_argument(
-        "--batch_size",
-        "-b",
-        type=int,
-        default=512,
-        help="what is the batch size for the training iterations",
-    )
-    parser.add_argument(
-        "--cold_start",
-        "--cold-start",
-        "-c",
-        dest="cold_start",
-        action="store_true",
-    )
-    parser.add_argument(
-        "--no_cold_start",
-        "--no-cold-start",
-        "--no-cold_start",
-        dest="cold_start",
-        action="store_false",
-    )
-    parser.set_defaults(cold_start=False)
-    parser.add_argument(
-        "--epochs",
-        "-e",
-        type=int,
-        default=24,
-        help="what is the number of epochs for each training iteration",
-    )
-    parser.add_argument(
-        "--fixes",
-        "-f",
-        type=str,
-        default="yes",
-        help="make fixes? options are yes, no, and perfect",
-    )
-    parser.add_argument(
-        "--model_path",
-        "-m",
-        "--model-path",
-        type=str,
-        default=None,
-        help="path to the file for loading/saving a model when applicable",
-    )
-    parser.add_argument(
-        "--num_rounds",
-        "--n_rounds",
-        "--num-rounds",
-        "--n-rounds",
-        type=int,
-        default=5,
-        help="how many rounds to execute in a run",
-    )
-    parser.add_argument(
-        "--num_max_samples",
-        "--n_max_samples",
-        "--num-max-samples",
-        "--n-max-samples" "--num-max",
-        "--num_max",
-        "--n_max",
-        "--n-max",
-        type=int,
-        default=-1,
-        help="max samples to use in a run (default will use all samples)",
-    )
-    parser.add_argument(
-        "--percent_corrupt",
-        "--p_corrupt",
-        "--percent-corrupt",
-        "--p-corrupt",
-        type=float,
-        default=0.2,
-        help="what percentage [0,1] of the data (annotations) to corrupt",
-    )
-    parser.add_argument(
-        "--percent_fixable",
-        "--p_fixable",
-        "--percent-fixable",
-        "--p-fixable",
-        type=float,
-        default=0.2,
-        help="what percentage [0,1] of the data is fixable each round",
-    )
-    parser.add_argument(
-        "--percent_initial",
-        "--p_initial",
-        "--percent-initial",
-        "--p-initial",
-        type=float,
-        default=0.25,
-        help="what percentage [0,1] of the data to use for initial training",
-    )
-    parser.add_argument(
-        "--runs",
-        "-r",
-        type=int,
-        default=6,
-        help="how many times to run the trial for statistical purposes",
-    )
-    parser.add_argument(
-        "--start_ipython",
-        "--start-ipython",
-        "-i",
-        dest="start_ipython",
-        action="store_true",
-    )
-    parser.add_argument(
-        "--no_start_ipython",
-        "--no-start-ipython",
-        "--no-start_ipython",
-        dest="start_ipython",
-        action="store_false",
-    )
-    parser.set_defaults(start_ipython=False)
-    parser.add_argument(
-        "--stats_path",
-        "-s",
-        "--stats-path",
-        type=str,
-        default=None,
-        help="path to the file for saving the statistics in json if desired",
-    )
-    parser.add_argument(
-        "--take",
-        "-t",
-        type=int,
-        default=None,
-        help="how many samples to take in a subset of the dataset (default is all)",
-    )
-
-    args = parser.parse_args()
-
-    return Config(args.__dict__)
diff --git a/production/models/simple_resnet/datasets.py b/production/models/simple_resnet/datasets.py
deleted file mode 100644
index 3c428a43..00000000
--- a/production/models/simple_resnet/datasets.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-Implementation of datasets for the experiments
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import lru_cache as cache
-
-import torch
-
-from utils import *
-
-
-class DataLoader:
-    def __init__(
-        self,
-        dataset,
-        batch_size,
-        shuffle,
-        set_random_choices=False,
-        num_workers=0,
-        drop_last=False,
-    ):
-        self.dataset = dataset
-        self.batch_size = batch_size
-        self.set_random_choices = set_random_choices
-        self.dataloader = torch.utils.data.DataLoader(
-            dataset,
-            batch_size=batch_size,
-            num_workers=num_workers,
-            pin_memory=True,
-            shuffle=shuffle,
-            drop_last=drop_last,
-        )
-
-    def __iter__(self):
-        if self.set_random_choices:
-            self.dataset.set_random_choices()
-        return (
-            {"input": x.to(device).half(), "target": y.to(device).long()}
-            for (x, y) in self.dataloader
-        )
-
-    def __len__(self):
-        return len(self.dataloader)
-
-
-@cache(None)
-def cifar10(root="./data"):
-    try:
-        import torchvision
-
-        download = lambda train: torchvision.datasets.CIFAR10(
-            root=root, train=train, download=True
-        )
-        return {
-            k: {"data": v.data, "targets": v.targets}
-            for k, v in [
-                ("train", download(train=True)),
-                ("valid", download(train=False)),
-            ]
-        }
-    except ImportError:
-        from tensorflow.keras import datasets
-
-        (
-            (train_images, train_labels),
-            (valid_images, valid_labels),
-        ) = datasets.cifar10.load_data()
-        return {
-            "train": {"data": train_images, "targets": train_labels.squeeze()},
-            "valid": {"data": valid_images, "targets": valid_labels.squeeze()},
-        }
-
-
-cifar10_mean, cifar10_std = [
-    (
-        125.31,
-        122.95,
-        113.87,
-    ),  # equals np.mean(cifar10()['train']['data'], axis=(0,1,2))
-    (
-        62.99,
-        62.09,
-        66.70,
-    ),  # equals np.std(cifar10()['train']['data'], axis=(0,1,2))
-]
-cifar10_classes = "airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck".split(
-    ", "
-)
diff --git a/production/models/simple_resnet/preprocess.py b/production/models/simple_resnet/preprocess.py
deleted file mode 100644
index 762c778a..00000000
--- a/production/models/simple_resnet/preprocess.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-Preprocessing functions
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-import copy
-from functools import singledispatch
-
-import numpy as np
-import torch
-from torch import nn
-
-
-## Data Preprocessing and Handling
-def preprocess(dataset, transforms):
-    dataset = copy.copy(dataset)  # shallow copy
-    for transform in transforms:
-        dataset["data"] = transform(dataset["data"])
-    return dataset
-
-
-@singledispatch
-def normalise(x, mean, std):
-    return (x - mean) / std
-
-
-@normalise.register(np.ndarray)
-def _(x, mean, std):
-    # faster inplace for numpy arrays
-    x = np.array(x, np.float32)
-    x -= mean
-    x *= 1.0 / std
-    return x
-
-
-unnormalise = lambda x, mean, std: x * std + mean
-
-
-@singledispatch
-def pad(x, border):
-    raise NotImplementedError
-
-
-@pad.register(np.ndarray)
-def _(x, border):
-    return np.pad(
-        x, [(0, 0), (border, border), (border, border), (0, 0)], mode="reflect"
-    )
-
-
-@pad.register(torch.Tensor)
-def _(x, border):
-    return nn.ReflectionPad2d(border)(x)
-
-
-@singledispatch
-def transpose(x, source, target):
-    raise NotImplementedError
-
-
-@transpose.register(np.ndarray)
-def _(x, source, target):
-    return x.transpose([source.index(d) for d in target])
-
-
-@transpose.register(torch.Tensor)
-def _(x, source, target):
-    return x.permute([source.index(d) for d in target])
-
-
-class Transform:
-    def __init__(self, dataset, transforms):
-        self.dataset, self.transforms = dataset, transforms
-        self.choices = None
-
-    def __len__(self):
-        return len(self.dataset)
-
-    def __getitem__(self, index):
-        data, labels = self.dataset[index]
-        data = data.copy()
-        for choices, f in zip(self.choices, self.transforms):
-            data = f(data, **choices[index])
-        return data, labels
-
-    def set_random_choices(self):
-        self.choices = []
-        x_shape = self.dataset[0][0].shape
-        N = len(self)
-        for t in self.transforms:
-            self.choices.append(np.random.choice(t.options(x_shape), N))
-            x_shape = (
-                t.output_shape(x_shape)
-                if hasattr(t, "output_shape")
-                else x_shape
-            )
diff --git a/production/models/simple_resnet/train_classifier.py b/production/models/simple_resnet/train_classifier.py
deleted file mode 100644
index 942512d7..00000000
--- a/production/models/simple_resnet/train_classifier.py
+++ /dev/null
@@ -1,234 +0,0 @@
-"""
-Trains a clean model using the brain.model simple_resnet class; it stores the
-weights in a file for publishing the model.
-
-Supports these config parameters:
-
-    - config.take
-    - config.epochs
-    - config.batch_size
-    - config.n_max
-    - config.p_initial
-    - config.n_rounds
-    - config.cold_start
-    - config.stats_path
-    - config.model_path
-
-A simple (minimal) set of these to run on a small machine is::
-
-    run train_classifier.py -t 2000 -e 12 -b 64 --n_rounds 1 --p_initial 1.0 -m /tmp/foo.pth
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import json
-import os
-import random
-import sys
-import time
-
-from fiftyone.brain.internal.models.simple_resnet import *
-
-from config import *
-from datasets import *
-from preprocess import *
-from training import *
-from utils import Timer
-
-
-TEMP_TRAIN_DIR = "/tmp/le_test/train"
-TEMP_VALID_DIR = "/tmp/le_test/valid"
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def main(config):
-
-    ## Initial Data Input
-    # Produces train_set and valid_set that are lists of tuples: (image, label)
-    timer = Timer()
-    whole_dataset = cifar10(root=DATA_DIR)
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    whole_train_set = list(
-        zip(
-            *preprocess(
-                whole_dataset["train"], [partial(pad, border=4)] + transforms
-            ).values()
-        )
-    )
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished loading and preprocessing in {timer():.2f} seconds")
-
-    print(f"train set: {len(whole_train_set)} samples")
-    print(f"valid set: {len(valid_set)} samples")
-
-    if config.take:
-        whole_train_set = whole_train_set[: config.take]
-        valid_set = whole_train_set[: config.take]
-        print(f"using a subset of the data")
-        print(f"train set: {len(whole_train_set)} samples")
-        print(f"valid set: {len(valid_set)} samples")
-
-    # function of dataset
-    N_labels = 10
-
-    # set up the variables for training the model in each increment of the
-    # dataset size
-    lr_schedule = PiecewiseLinear([0, 5, config.epochs], [0, 0.4, 0])
-    train_transforms = [Crop(32, 32), FlipLR(), Cutout(8, 8)]
-
-    # compute the derived parameters for the trial based on the dataset and the
-    # provided config.
-    total_N = len(whole_train_set)
-
-    # should be cleaner
-    if config.n_max < 0:
-        config.n_max = total_N
-
-    start_N = round(config.p_initial * total_N)
-
-    incr_N = (
-        0
-        if config.n_rounds == 1
-        else round((config.n_max - start_N) / (config.n_rounds - 1))
-    )
-
-    print(f"Setting up the experiment: {total_N} training samples.")
-    print(f"- starting with {start_N}")
-    print(f"- incrementing by {incr_N} for each of {config.n_rounds-1} rounds")
-    print(f"- total rounds: {config.n_rounds}")
-
-    print(f"Starting the model training at {localtime()}")
-
-    inuse_N = start_N
-
-    model = simple_resnet().to(device).half()
-    logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-    valid_batches = DataLoader(
-        valid_set, config.batch_size, shuffle=False, drop_last=False
-    )
-
-    whole_train_set_use = whole_train_set[0:inuse_N]
-    whole_train_set_avail = whole_train_set[inuse_N:]
-    print(
-        f"Split training set into two; "
-        + f"using {len(whole_train_set_use)}, "
-        + f"available {len(whole_train_set_avail)}"
-    )
-
-    stats = {}
-
-    for iteration in range(config.n_rounds):
-        print(f"beginning next round of training, using {inuse_N} samples")
-
-        if config.cold_start:
-            model = simple_resnet().to(device).half()
-            logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-        train_batches = DataLoader(
-            Transform(whole_train_set_use, train_transforms),
-            config.batch_size,
-            shuffle=True,
-            set_random_choices=True,
-            drop_last=True,
-        )
-        lr = lambda step: (
-            lr_schedule(step / len(train_batches)) / config.batch_size
-        )
-        opts = [
-            SGD(
-                trainable_params(model).values(),
-                {
-                    "lr": lr,
-                    "weight_decay": Const(5e-4 * config.batch_size),
-                    "momentum": Const(0.9),
-                },
-            )
-        ]
-        state[OPTS] = opts
-
-        for epoch in range(config.epochs):
-            logs.append(
-                union(
-                    {"epoch": epoch + 1},
-                    train_epoch(
-                        state,
-                        Timer(torch.cuda.synchronize),
-                        train_batches,
-                        valid_batches,
-                    ),
-                )
-            )
-        (
-            logs.df()
-            .query(f"epoch=={config.epochs}")[["train_acc", "valid_acc"]]
-            .describe()
-        )
-
-        model.train(False)
-
-        # record scores for this iteration
-        iteration_stats = {}
-        iteration_stats["in_use"] = inuse_N
-
-        correct = 0
-        total = 0
-        class_correct = list(0.0 for i in range(10))
-        class_total = list(0.0 for i in range(10))
-        with torch.no_grad():
-            for data in valid_batches.dataloader:
-                images, labels = data
-                y = model(images.cuda().half())
-                _, predicted = torch.max(y, 1)
-                total += labels.size(0)
-                labels_gpu = labels.cuda().half()
-                correct += (predicted == labels_gpu).sum().item()
-                c = (predicted == labels_gpu).squeeze()
-                for i in range(min(config.batch_size, len(labels))):
-                    label = labels[i]
-                    class_correct[label] += c[i].item()
-                    class_total[label] += 1
-
-        iteration_stats["validation_accuracy"] = correct / total
-
-        model.train(True)
-
-        # extend the corr_train_set_use with that from avail
-        whole_train_set_use.extend(whole_train_set_avail[0:incr_N])
-        whole_train_set_avail = whole_train_set_avail[incr_N:]
-        inuse_N += incr_N
-        assert inuse_N == len(whole_train_set_use)
-
-        stats[inuse_N] = iteration_stats
-
-    print(f"finished the full training; stats to follow")
-    print(stats)
-
-    if config.stats_path:
-        with open(config.stats_path, "w") as fp:
-            json.dump(stats, fp)
-
-    if config.model_path:
-        torch.save(model.state_dict(), config.model_path)
-
-
-if __name__ == "__main__":
-
-    config = commandline()
-
-    print(f"running with config: {config}")
-
-    main(config)
diff --git a/production/models/simple_resnet/train_classifier_cifar10.bash b/production/models/simple_resnet/train_classifier_cifar10.bash
deleted file mode 100644
index 08011ccb..00000000
--- a/production/models/simple_resnet/train_classifier_cifar10.bash
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-
-echo "Production Training Script for simple_resnet model on CIFAR-10"
-
-MODEL_PATH="simple_resnet_cifar10.pth"
-
-python train_classifier.py -e 30 -b 512 --n_rounds 1 --p_initial 1.0 -m $MODEL_PATH
-
-python test_classifier.py -m $MODEL_PATH
-
-echo "Model trained and tested; refer to the README.md for deployment"
diff --git a/production/models/simple_resnet/training.py b/production/models/simple_resnet/training.py
deleted file mode 100644
index 06318d4e..00000000
--- a/production/models/simple_resnet/training.py
+++ /dev/null
@@ -1,251 +0,0 @@
-"""
-Training functions
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from collections import namedtuple
-from functools import partial, singledispatch
-from itertools import chain
-
-import numpy as np
-import torch
-
-from fiftyone.brain.internal.models.simple_resnet import *
-
-from utils import *
-
-
-@singledispatch
-def to_numpy(x):
-    raise NotImplementedError
-
-
-@to_numpy.register(torch.Tensor)
-def _(x):
-    return x.detach().cpu().numpy()
-
-
-## Losses, Optimizers
-
-
-class CrossEntropyLoss(namedtuple("CrossEntropyLoss", [])):
-    def __call__(self, log_probs, target):
-        return torch.nn.functional.nll_loss(
-            log_probs, target, reduction="none"
-        )
-
-
-class KLLoss(namedtuple("KLLoss", [])):
-    def __call__(self, log_probs):
-        return -log_probs.mean(dim=1)
-
-
-class Correct(namedtuple("Correct", [])):
-    def __call__(self, classifier, target):
-        return classifier.max(dim=1)[1] == target
-
-
-class LogSoftmax(namedtuple("LogSoftmax", ["dim"])):
-    def __call__(self, x):
-        return torch.nn.functional.log_softmax(x, self.dim, _stacklevel=5)
-
-
-x_ent_loss = Network(
-    {
-        "loss": (nn.CrossEntropyLoss(reduction="none"), ["logits", "target"]),
-        "acc": (Correct(), ["logits", "target"]),
-    }
-)
-
-label_smoothing_loss = lambda alpha: Network(
-    {
-        "logprobs": (LogSoftmax(dim=1), ["logits"]),
-        "KL": (KLLoss(), ["logprobs"]),
-        "xent": (CrossEntropyLoss(), ["logprobs", "target"]),
-        "loss": (AddWeighted(wx=1 - alpha, wy=alpha), ["xent", "KL"]),
-        "acc": (Correct(), ["logits", "target"]),
-    }
-)
-
-trainable_params = lambda model: (
-    {k: p for k, p in model.named_parameters() if p.requires_grad}
-)
-
-
-def nesterov_update(w, dw, v, lr, weight_decay, momentum):
-    dw.add_(weight_decay, w).mul_(-lr)
-    v.mul_(momentum).add_(dw)
-    w.add_(dw.add_(momentum, v))
-
-
-norm = lambda x: torch.norm(x.reshape(x.size(0), -1).float(), dim=1)[
-    :, None, None, None
-]
-
-
-def LARS_update(w, dw, v, lr, weight_decay, momentum):
-    nesterov_update(
-        w,
-        dw,
-        v,
-        lr * (norm(w) / (norm(dw) + 1e-2)).to(w.dtype),
-        weight_decay,
-        momentum,
-    )
-
-
-def zeros_like(weights):
-    return [torch.zeros_like(w) for w in weights]
-
-
-def optimiser(weights, param_schedule, update, state_init):
-    weights = list(weights)
-    return {
-        "update": update,
-        "param_schedule": param_schedule,
-        "step_number": 0,
-        "weights": weights,
-        "opt_state": state_init(weights),
-    }
-
-
-def opt_step(update, param_schedule, step_number, weights, opt_state):
-    step_number += 1
-    param_values = {k: f(step_number) for k, f in param_schedule.items()}
-    for w, v in zip(weights, opt_state):
-        if w.requires_grad:
-            update(w.data, w.grad.data, v, **param_values)
-
-    return {
-        "update": update,
-        "param_schedule": param_schedule,
-        "step_number": step_number,
-        "weights": weights,
-        "opt_state": opt_state,
-    }
-
-
-LARS = partial(optimiser, update=LARS_update, state_init=zeros_like)
-SGD = partial(optimiser, update=nesterov_update, state_init=zeros_like)
-
-
-## Training Code
-LOSS = "loss"
-OPTS = "optimisers"
-ACT_LOG = "activation_log"
-
-
-def reduce(batches, state, steps):
-    # state: is a dictionary
-    # steps: are functions that take (batch, state)
-    # and return a dictionary of updates to the state (or None)
-
-    for batch in chain(batches, [None]):
-        # we send an extra batch=None at the end for steps that
-        # need to do some tidying-up (e.g. log_activations)
-        for step in steps:
-            updates = step(batch, state)
-            if updates:
-                for k, v in updates.items():
-                    state[k] = v
-
-    return state
-
-
-def forward(training_mode):
-    def step(batch, state):
-        if not batch:
-            return
-
-        model = (
-            state[MODEL]
-            if training_mode or (VALID_MODEL not in state)
-            else state[VALID_MODEL]
-        )
-        if model.training != training_mode:  # without the guard it's slow!
-            model.train(training_mode)
-
-        return {OUTPUT: state[LOSS](model(batch))}
-
-    return step
-
-
-def backward(dtype=None):
-    def step(batch, state):
-        state[MODEL].zero_grad()
-        if not batch:
-            return
-
-        loss = state[OUTPUT][LOSS]
-        if dtype is not None:
-            loss = loss.to(dtype)
-
-        loss.sum().backward()
-
-    return step
-
-
-def opt_steps(batch, state):
-    if not batch:
-        return
-
-    return {OPTS: [opt_step(**opt) for opt in state[OPTS]]}
-
-
-def log_activations(node_names=("loss", "acc")):
-    def step(batch, state):
-        if "_tmp_logs_" not in state:
-            state["_tmp_logs_"] = []
-        if batch:
-            state["_tmp_logs_"].extend(
-                (k, state[OUTPUT][k].detach()) for k in node_names
-            )
-        else:
-            res = {
-                k: to_numpy(torch.cat(xs)).astype(np.float)
-                for k, xs in group_by_key(state["_tmp_logs_"]).items()
-            }
-            del state["_tmp_logs_"]
-            return {ACT_LOG: res}
-
-    return step
-
-
-epoch_stats = lambda state: {k: np.mean(v) for k, v in state[ACT_LOG].items()}
-
-default_train_steps = (
-    forward(training_mode=True),
-    log_activations(("loss", "acc")),
-    backward(),
-    opt_steps,
-)
-default_valid_steps = (
-    forward(training_mode=False),
-    log_activations(("loss", "acc")),
-)
-
-
-def train_epoch(
-    state,
-    timer,
-    train_batches,
-    valid_batches,
-    train_steps=default_train_steps,
-    valid_steps=default_valid_steps,
-    on_epoch_end=(lambda state: state),
-):
-    train_summary, train_time = (
-        epoch_stats(on_epoch_end(reduce(train_batches, state, train_steps))),
-        timer(),
-    )
-    valid_summary, valid_time = (
-        epoch_stats(reduce(valid_batches, state, valid_steps)),
-        timer(include_in_total=False),
-    )  # DAWNBench rules
-    return {
-        "train": union({"time": train_time}, train_summary),
-        "valid": union({"time": valid_time}, valid_summary),
-        "total time": timer.total_time,
-    }
diff --git a/production/models/simple_resnet/utils.py b/production/models/simple_resnet/utils.py
deleted file mode 100644
index 6878a86b..00000000
--- a/production/models/simple_resnet/utils.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""
-Utilities for experiments.
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from collections import defaultdict
-import pandas as pd
-import time
-
-import numpy as np
-import torch
-
-
-device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-cpu = torch.device("cpu")
-
-
-class Timer:
-    def __init__(self, synch=None):
-        self.synch = synch or (lambda: None)
-        self.synch()
-        self.times = [time.perf_counter()]
-        self.total_time = 0.0
-
-    def __call__(self, include_in_total=True):
-        self.synch()
-        self.times.append(time.perf_counter())
-        delta_t = self.times[-1] - self.times[-2]
-        if include_in_total:
-            self.total_time += delta_t
-        return delta_t
-
-
-sep = "/"
-
-
-def split(path):
-    i = path.rfind(sep) + 1
-    return path[:i].rstrip(sep), path[i:]
-
-
-def normpath(path):
-    # simplified os.path.normpath
-    parts = []
-    for p in path.split(sep):
-        if p == "..":
-            parts.pop()
-        elif p.startswith(sep):
-            parts = [p]
-        else:
-            parts.append(p)
-    return sep.join(parts)
-
-
-union = lambda *dicts: {k: v for d in dicts for (k, v) in d.items()}
-
-
-def path_iter(nested_dict, pfx=()):
-    for name, val in nested_dict.items():
-        if isinstance(val, dict):
-            yield from path_iter(val, (*pfx, name))
-        else:
-            yield ((*pfx, name), val)
-
-
-def map_nested(func, nested_dict):
-    return {
-        k: map_nested(func, v) if isinstance(v, dict) else func(v)
-        for k, v in nested_dict.items()
-    }
-
-
-def group_by_key(items):
-    res = defaultdict(list)
-    for k, v in items:
-        res[k].append(v)
-    return res
-
-
-default_table_formats = {
-    float: "{:{w}.4f}",
-    str: "{:>{w}s}",
-    "default": "{:{w}}",
-    "title": "{:>{w}s}",
-}
-
-
-def table_formatter(val, is_title=False, col_width=12, formats=None):
-    formats = formats or default_table_formats
-    type_ = (
-        lambda val: float if isinstance(val, (float, np.float)) else type(val)
-    )
-    return (
-        formats["title"]
-        if is_title
-        else formats.get(type_(val), formats["default"])
-    ).format(val, w=col_width)
-
-
-def every(n, col):
-    return lambda data: data[col] % n == 0
-
-
-class Table:
-    def __init__(
-        self, keys=None, report=(lambda data: True), formatter=table_formatter
-    ):
-        self.keys, self.report, self.formatter = keys, report, formatter
-        self.log = []
-
-    def append(self, data):
-        self.log.append(data)
-        data = {" ".join(p): v for p, v in path_iter(data)}
-        self.keys = self.keys or data.keys()
-        if len(self.log) is 1:
-            print(*(self.formatter(k, True) for k in self.keys))
-        if self.report(data):
-            print(*(self.formatter(data[k]) for k in self.keys))
-
-    def df(self):
-        return pd.DataFrame(
-            [{"_".join(p): v for p, v in path_iter(row)} for row in self.log]
-        )
diff --git a/requirements/dev.txt b/requirements/dev.txt
index d73bf272..fc65a9ee 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -3,8 +3,9 @@
 flickrapi==2.4.0
 imageio==2.8.0
 ipython>=7.16.1
-pandas==1.1.5
+pandas
 pre-commit==2.0.1
 pylint==2.3.1
 pytest==7.3.1
 twine>=3
+voxel51-eta[storage]
diff --git a/setup.py b/setup.py
index 4532da97..132c4d6a 100644
--- a/setup.py
+++ b/setup.py
@@ -10,14 +10,7 @@
 from setuptools import setup
 
 
-with open("PYPI_README.md", "r") as fh:
-    long_description = fh.read()
-
-with open("LICENSE", "r") as fh:
-    long_description += "\n## License\n\n" + fh.read()
-
-
-VERSION = "0.17.0"
+VERSION = "0.18.0"
 
 
 def get_version():
@@ -33,6 +26,10 @@ def get_version():
     return VERSION
 
 
+with open("README.md", "r") as fh:
+    long_description = fh.read()
+
+
 setup(
     name="fiftyone-brain",
     version=get_version(),
@@ -40,7 +37,7 @@ def get_version():
     author="Voxel51, Inc.",
     author_email="info@voxel51.com",
     url="https://github.com/voxel51/fiftyone-brain",
-    license="Freeware (Custom)",
+    license="Apache",
     long_description=long_description,
     long_description_content_type="text/markdown",
     packages=["fiftyone.brain"],
@@ -50,7 +47,7 @@ def get_version():
         "Development Status :: 4 - Beta",
         "Intended Audience :: Developers",
         "Intended Audience :: Science/Research",
-        "License :: Freeware",
+        "License :: OSI Approved :: Apache Software License",
         "Topic :: Scientific/Engineering :: Artificial Intelligence",
         "Topic :: Scientific/Engineering :: Image Processing",
         "Topic :: Scientific/Engineering :: Image Recognition",
@@ -60,11 +57,10 @@ def get_version():
         "Operating System :: POSIX :: Linux",
         "Operating System :: Microsoft :: Windows",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
     ],
     scripts=[],
-    python_requires=">=3.8",
+    python_requires=">=3.9",
 )


Test patch:
diff --git a/experiments/labelerror/resnet_cifar10/le_test1.old b/experiments/labelerror/resnet_cifar10/le_test1.old
deleted file mode 100644
index 6e228b11..00000000
--- a/experiments/labelerror/resnet_cifar10/le_test1.old
+++ /dev/null
@@ -1,123 +0,0 @@
-"""
-le_test1 --> initial tests for using fiftyone with this code
-This test just loads the dataset into fiftyone and exists.
-It times the loading, etc...
-
-| Copyright 2017-2022, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import os
-import time
-
-from imageio import imsave
-
-import fiftyone as fo
-
-from config import *
-from datasets import *
-from simple_resnet import *
-from utils import Timer
-
-
-TEMP_TRAIN_DIR = "/tmp/le_test1/train"
-TEMP_VALID_DIR = "/tmp/le_test1/valid"
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def write_images(root, images, overwrite=False):
-    paths = []
-    for i, s in enumerate(images):
-        path = os.path.join(root, f"{i:05d}.jpg")
-        paths.append(path)
-
-        if overwrite or not os.path.exists(path):
-            img = s.copy()
-            img = transpose(img, source="CHW", target="HWC")
-            imsave(path, img)
-
-    return paths
-
-
-def main():
-    #
-    # Load dataset
-    #
-    # `train_set` and `valid_set` that are lists of `(image, label)` tuples
-    #
-
-    timer = Timer()
-    whole_dataset = cifar10(root=DATA_DIR)
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    whole_train_set = list(
-        zip(
-            *preprocess(
-                whole_dataset["train"], [partial(pad, border=4)] + transforms
-            ).values()
-        )
-    )
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished loading and preprocessing in {timer():.2f} seconds")
-
-    print(f"train set: {len(whole_train_set)} samples")
-    print(f"valid set: {len(valid_set)} samples")
-
-    # Write raw dataset to disk
-    os.makedirs(TEMP_TRAIN_DIR, exist_ok=True)
-    os.makedirs(TEMP_VALID_DIR, exist_ok=True)
-    train_image_paths = write_images(
-        TEMP_TRAIN_DIR, list(zip(*whole_train_set))[0]
-    )
-    valid_image_paths = write_images(TEMP_VALID_DIR, list(zip(*valid_set))[0])
-
-    #
-    # Load data into FiftyOne
-    #
-
-    timer = Timer()
-    dataset = fo.Dataset("le_cifar10")
-
-    # Train split
-    train_samples = []
-    for i, s in enumerate(whole_train_set):
-        train_samples.append(
-            fo.Sample(
-                train_image_paths[i],
-                tags=["train"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    dataset.add_samples(train_samples)
-
-    # Valid split
-    valid_samples = []
-    for i, s in enumerate(valid_set):
-        valid_samples.append(
-            fo.Sample(
-                valid_image_paths[i],
-                tags=["valid"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    dataset.add_samples(valid_samples)
-
-    print(f"Finished getting data into fiftyone in {timer():.2f} seconds")
-    print(dataset.summary())
-
-
-if __name__ == "__main__":
-    main()
diff --git a/experiments/labelerror/resnet_cifar10/le_test2.py b/experiments/labelerror/resnet_cifar10/le_test2.py
deleted file mode 100644
index 0355a8a1..00000000
--- a/experiments/labelerror/resnet_cifar10/le_test2.py
+++ /dev/null
@@ -1,277 +0,0 @@
-"""
-le_test2 --> initial tests for using fiftyone with this code
-Loads the data, assigns ground-truth labels, then trains a clean model
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import json
-import os
-import time
-
-from imageio import imsave
-
-import fiftyone as fo
-
-from config import *
-from datasets import *
-from simple_resnet import *
-from utils import Timer
-
-
-TEMP_TRAIN_DIR = "/tmp/le_test/train"
-TEMP_VALID_DIR = "/tmp/le_test/valid"
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def write_images(root, images, overwrite=False):
-    paths = []
-    for i, s in enumerate(images):
-        path = os.path.join(root, f"{i:05d}.jpg")
-        paths.append(path)
-
-        if overwrite or not os.path.exists(path):
-            img = s.copy()
-            img = transpose(img, source="CHW", target="HWC")
-            imsave(path, img)
-
-    return paths
-
-
-def main(config):
-    #
-    # Load dataset
-    #
-    # `train_set` and `valid_set` that are lists of `(image, label)` tuples
-    #
-
-    timer = Timer()
-    whole_dataset = cifar10(root=DATA_DIR)
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    whole_train_set = list(
-        zip(
-            *preprocess(
-                whole_dataset["train"], [partial(pad, border=4)] + transforms
-            ).values()
-        )
-    )
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished loading and preprocessing in {timer():.2f} seconds")
-
-    print(f"train set: {len(whole_train_set)} samples")
-    print(f"valid set: {len(valid_set)} samples")
-
-    # Write raw dataset to disk
-    os.makedirs(TEMP_TRAIN_DIR, exist_ok=True)
-    os.makedirs(TEMP_VALID_DIR, exist_ok=True)
-    train_image_paths = write_images(
-        TEMP_TRAIN_DIR, list(zip(*whole_train_set))[0]
-    )
-    valid_image_paths = write_images(TEMP_VALID_DIR, list(zip(*valid_set))[0])
-
-    #
-    # Load data into FiftyOne
-    #
-
-    timer = Timer()
-    dataset = fo.Dataset("le_cifar10")
-
-    # Train split
-    train_samples = []
-    for i, s in enumerate(whole_train_set):
-        train_samples.append(
-            fo.Sample(
-                train_image_paths[i],
-                tags=["train"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    train_ids = dataset.add_samples(train_samples)
-
-    # Valid split
-    valid_samples = []
-    for i, s in enumerate(valid_set):
-        valid_samples.append(
-            fo.Sample(
-                valid_image_paths[i],
-                tags=["valid"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    valid_ids = dataset.add_samples(valid_samples)
-
-    print(f"Finished getting data into fiftyone in {timer():.2f} seconds")
-    print(dataset.summary())
-
-    # function of dataset
-    N_labels = 10
-
-    # set up the variables for training the model in each increment of the
-    # dataset size
-    lr_schedule = PiecewiseLinear([0, 5, config.epochs], [0, 0.4, 0])
-    train_transforms = [Crop(32, 32), FlipLR(), Cutout(8, 8)]
-
-    # compute the derived parameters for the trial based on the dataset and the
-    # provided config.
-    total_N = len(whole_train_set)
-
-    # should be cleaner
-    if config.n_max < 0:
-        config.n_max = total_N
-
-    start_N = round(config.p_initial * total_N)
-
-    incr_N = (
-        0
-        if config.n_rounds == 1
-        else round((config.n_max - start_N) / (config.n_rounds - 1))
-    )
-
-    corrupt_N = round(config.p_corrupt * total_N)
-
-    print(f"Setting up the experiment: {total_N} training samples.")
-    print(f"- starting with {start_N}")
-    print(f"- incrementing by {incr_N} for each of {config.n_rounds-1} rounds")
-    print(f"- total rounds: {config.n_rounds}")
-
-    print(f"Starting the model training at {localtime()}")
-
-    inuse_N = start_N
-
-    model = Network(simple_resnet()).to(device).half()
-    logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-    valid_batches = DataLoader(
-        valid_set, config.batch_size, shuffle=False, drop_last=False
-    )
-
-    # initially randomly shuffle the dataset and take the initial number of
-    # samples
-    whole_train_set_use = whole_train_set[0:inuse_N]
-    whole_train_set_avail = whole_train_set[inuse_N:]
-    print(
-        f"Split training set into two; using {len(whole_train_set_use)}, "
-        + f"available {len(whole_train_set_avail)}"
-    )
-
-    sm = torch.nn.Softmax(dim=1)
-
-    stats = {}
-
-    for iteration in range(config.n_rounds):
-        print(f"beginning next round of training, using {inuse_N} samples")
-
-        if config.cold_start:
-            model = Network(simple_resnet()).to(device).half()
-            logs, state = Table(), {MODEL: model, LOSS: x_ent_loss}
-
-        train_batches = DataLoader(
-            Transform(whole_train_set_use, train_transforms),
-            config.batch_size,
-            shuffle=True,
-            set_random_choices=True,
-            drop_last=True,
-        )
-        lr = (
-            lambda step: lr_schedule(step / len(train_batches))
-            / config.batch_size
-        )
-        opts = [
-            SGD(
-                trainable_params(model).values(),
-                {
-                    "lr": lr,
-                    "weight_decay": Const(5e-4 * config.batch_size),
-                    "momentum": Const(0.9),
-                },
-            )
-        ]
-        state[OPTS] = opts
-
-        for epoch in range(config.epochs):
-            logs.append(
-                union(
-                    {"epoch": epoch + 1},
-                    train_epoch(
-                        state,
-                        Timer(torch.cuda.synchronize),
-                        train_batches,
-                        valid_batches,
-                    ),
-                )
-            )
-        logs.df().query(f"epoch=={config.epochs}")[
-            ["train_acc", "valid_acc"]
-        ].describe()
-
-        model.train(False)  # == model.eval()
-
-        # record scores for this iteration
-        iteration_stats = {}
-        iteration_stats["in_use"] = inuse_N
-
-        correct = 0
-        total = 0
-        class_correct = list(0.0 for i in range(10))
-        class_total = list(0.0 for i in range(10))
-        with torch.no_grad():
-            for data in valid_batches.dataloader:
-                images, labels = data
-                inputs = dict(input=images.cuda().half())
-                outputs = model(inputs)
-                y = outputs["logits"]
-                _, predicted = torch.max(y, 1)
-                total += labels.size(0)
-                labels_gpu = labels.cuda().half()
-                correct += (predicted == labels_gpu).sum().item()
-                c = (predicted == labels_gpu).squeeze()
-                for i in range(min(config.batch_size, len(labels))):
-                    label = labels[i]
-                    class_correct[label] += c[i].item()
-                    class_total[label] += 1
-
-        iteration_stats["validation_accuracy"] = correct / total
-
-        model.train(True)
-
-        # extend the corr_train_set_use with that from avail
-        whole_train_set_use.extend(whole_train_set_avail[0:incr_N])
-        whole_train_set_avail = whole_train_set_avail[incr_N:]
-        inuse_N += incr_N
-        assert inuse_N == len(whole_train_set_use)
-
-        stats[inuse_N] = iteration_stats
-
-    print(f"finished the full training; stats to follow")
-    print(stats)
-
-    if config.stats_path:
-        with open(config.stats_path, "w") as fp:
-            json.dump(stats, fp)
-
-    if config.model_path:
-        torch.save(model.state_dict(), config.model_path)
-
-
-if __name__ == "__main__":
-
-    config = commandline()
-
-    print(f"running with config: {config}")
-
-    main(config)
diff --git a/experiments/labelerror/resnet_cifar10/le_test3.py b/experiments/labelerror/resnet_cifar10/le_test3.py
deleted file mode 100644
index 2ddd725e..00000000
--- a/experiments/labelerror/resnet_cifar10/le_test3.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""
-le_test3 --> initial tests for using fiftyone with this code
-Loads the data, assigns ground-truth labels, loads a model
-Runs prediction and associates predictions with the fiftyone dataset
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import os
-import time
-
-import ipdb
-from imageio import imsave
-
-import fiftyone as fo
-
-from config import *
-from datasets import *
-from simple_resnet import *
-from utils import Timer
-
-
-TEMP_TRAIN_DIR = "/tmp/le_test/train"
-TEMP_VALID_DIR = "/tmp/le_test/valid"
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def write_images(root, images, overwrite=False):
-    paths = []
-    for i, s in enumerate(images):
-        path = os.path.join(root, f"{i:05d}.jpg")
-        paths.append(path)
-
-        if overwrite or not os.path.exists(path):
-            img = s.copy()
-            img = transpose(img, source="CHW", target="HWC")
-            imsave(path, img)
-
-    return paths
-
-
-def main(config):
-    #
-    # Load dataset
-    #
-    # `train_set` and `valid_set` that are lists of `(image, label)` tuples
-    #
-
-    timer = Timer()
-    whole_dataset = cifar10(root=DATA_DIR)
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    whole_train_set = list(
-        zip(
-            *preprocess(
-                whole_dataset["train"], [partial(pad, border=4)] + transforms
-            ).values()
-        )
-    )
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished loading and preprocessing in {timer():.2f} seconds")
-
-    print(f"train set: {len(whole_train_set)} samples")
-    print(f"valid set: {len(valid_set)} samples")
-
-    if config.take:
-        whole_train_set = whole_train_set[: config.take]
-        valid_set = whole_train_set[: config.take]
-        print(f"using a subset of the data")
-        print(f"train set: {len(whole_train_set)} samples")
-        print(f"valid set: {len(valid_set)} samples")
-
-    # Write raw dataset to disk
-    os.makedirs(TEMP_TRAIN_DIR, exist_ok=True)
-    os.makedirs(TEMP_VALID_DIR, exist_ok=True)
-    train_image_paths = write_images(
-        TEMP_TRAIN_DIR, list(zip(*whole_train_set))[0]
-    )
-    valid_image_paths = write_images(TEMP_VALID_DIR, list(zip(*valid_set))[0])
-
-    #
-    # Load data into FiftyOne
-    #
-
-    timer = Timer()
-    dataset = fo.Dataset("le_cifar10")
-
-    # Train split
-    train_samples = []
-    for i, s in enumerate(whole_train_set):
-        train_samples.append(
-            fo.Sample(
-                train_image_paths[i],
-                tags=["train"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    train_ids = dataset.add_samples(train_samples)
-
-    # Valid split
-    valid_samples = []
-    for i, s in enumerate(valid_set):
-        valid_samples.append(
-            fo.Sample(
-                valid_image_paths[i],
-                tags=["valid"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    valid_ids = dataset.add_samples(valid_samples)
-
-    print(f"Finished getting data into fiftyone in {timer():.2f} seconds")
-    print(dataset.summary())
-
-    # load the model using the model path config
-    assert config.model_path
-    model = Network(simple_resnet()).to(device).half()
-    model.load_state_dict(torch.load(config.model_path))
-
-    model.train(False)  # == model.eval()
-
-    # I need to get my datasets into a format where I'll have the ids available
-    # as well during the data loading
-    train_imgs, train_labels = zip(*whole_train_set)
-    fo_train_set = list(zip(train_imgs, train_labels, train_ids))
-
-    valid_imgs, valid_labels = zip(*valid_set)
-    fo_valid_set = list(zip(valid_imgs, valid_labels, valid_ids))
-
-    train_batches = DataLoader(
-        fo_train_set, config.batch_size, shuffle=False, drop_last=False
-    )
-    valid_batches = DataLoader(
-        fo_valid_set, config.batch_size, shuffle=False, drop_last=False
-    )
-
-    correct = 0
-    total = 0
-    class_correct = list(0.0 for i in range(10))
-    class_total = list(0.0 for i in range(10))
-    with torch.no_grad():
-        for images, labels, sample_ids in valid_batches.dataloader:
-            inputs = dict(input=images.cuda().half())
-            outputs = model(inputs)
-            y = outputs["logits"]
-            _, predicted = torch.max(y, 1)
-            total += labels.size(0)
-            labels_gpu = labels.cuda().half()
-            correct += (predicted == labels_gpu).sum().item()
-            c = (predicted == labels_gpu).squeeze()
-            for i in range(min(config.batch_size, len(labels))):
-                label = labels[i]
-                class_correct[label] += c[i].item()
-                class_total[label] += 1
-
-            # Add predictions to FiftyOne dataset
-            for prediction, sample_id, logits in zip(predicted, sample_ids, y):
-                sample = dataset[sample_id]
-                sample["prediction"] = fo.Classification(
-                    label=cifar10_classes[prediction], logits=logits,
-                )
-                sample["max-logit"] = np.max(logits.cpu().numpy())
-                sample.save()
-
-    print(
-        "Accuracy of the network on the 10K test images: %.2f%%"
-        % (100 * correct / total)
-    )
-
-    for i in range(10):
-        print(
-            "Accuracy of %9s : %.2f%%"
-            % (cifar10_classes[i], 100 * class_correct[i] / class_total[i])
-        )
-
-    print("done")
-
-    if config.start_ipython:
-        ipdb.set_trace()
-
-    return dataset
-
-
-if __name__ == "__main__":
-
-    config = commandline()
-
-    print(f"running with config: {config}")
-
-    dataset = main(config)
diff --git a/production/models/simple_resnet/test_classifier.py b/production/models/simple_resnet/test_classifier.py
deleted file mode 100644
index 6d661739..00000000
--- a/production/models/simple_resnet/test_classifier.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""
-Tests a model by loading the cifar10 dataset and then running predictions
-against it.
-
-@todo Update this to use the actual class functionality of the model rather
-than the low-level model setup.
-
-Run with at least the following command line:
-    -m model_path.pth
-
-Uses
-    -t number
-
-| Copyright 2017-2024, Voxel51, Inc.
-| `voxel51.com <https://voxel51.com/>`_
-|
-"""
-from functools import partial
-import os
-import time
-
-from imageio import imsave
-
-import fiftyone as fo
-
-from fiftyone.brain.internal.models.simple_resnet import *
-
-from preprocess import *
-from config import *
-from datasets import *
-from utils import Timer
-
-
-TEMP_VALID_DIR = "/tmp/le_test/valid"
-
-localtime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
-
-
-def write_images(root, images, overwrite=False):
-    paths = []
-    for i, s in enumerate(images):
-        path = os.path.join(root, f"{i:05d}.jpg")
-        paths.append(path)
-
-        if overwrite or not os.path.exists(path):
-            img = s.copy()
-            img = transpose(img, source="CHW", target="HWC")
-            imsave(path, img)
-
-    return paths
-
-
-def main(config):
-
-    ## Initial Data Input
-    # Produces train_set and valid_set that are lists of tuples: (image, label)
-    # (only the valid_set will be used in this script)
-    timer = Timer()
-    whole_dataset = cifar10(root=DATA_DIR)
-    print("Preprocessing training data")
-    transforms = [
-        partial(
-            normalise,
-            mean=np.array(cifar10_mean, dtype=np.float32),
-            std=np.array(cifar10_std, dtype=np.float32),
-        ),
-        partial(transpose, source="NHWC", target="NCHW"),
-    ]
-    valid_set = list(
-        zip(*preprocess(whole_dataset["valid"], transforms).values())
-    )
-    print(f"Finished loading and preprocessing in {timer():.2f} seconds")
-
-    print(f"valid set: {len(valid_set)} samples")
-
-    if config.take:
-        valid_set = valid_set[: config.take]
-        print(f"using a subset of the data")
-        print(f"valid set: {len(valid_set)} samples")
-
-    # Write raw dataset to disk
-    os.makedirs(TEMP_VALID_DIR, exist_ok=True)
-    valid_image_paths = write_images(TEMP_VALID_DIR, list(zip(*valid_set))[0])
-
-    # make the actual labels for the cifar-10 world
-    labels = []
-    for i, s in enumerate(cifar10_classes):
-        labels.append(fo.Classification(label=s))
-
-    #
-    # Load data into FiftyOne
-    #
-
-    timer = Timer()
-    dataset = fo.Dataset("le_cifar10")
-
-    valid_samples = []
-    for i, s in enumerate(valid_set):
-        valid_samples.append(
-            fo.Sample(
-                valid_image_paths[i],
-                tags=["valid"],
-                ground_truth=fo.Classification(label=cifar10_classes[s[1]]),
-            )
-        )
-
-    valid_ids = dataset.add_samples(valid_samples)
-
-    print(f"Finished getting data into fiftyone in {timer():.2f} seconds")
-
-    # load the model using the model path config
-    assert config.model_path
-    model = simple_resnet().to(device).half()
-    model.load_state_dict(torch.load(config.model_path))
-
-    print("Model loaded.")
-
-    model.train(False)
-
-    # I need to get my datasets into a format where I'll have the ids available
-    # as well during the data loading
-
-    valid_imgs, valid_labels = zip(*valid_set)
-    fo_valid_set = list(zip(valid_imgs, valid_labels, valid_ids))
-
-    valid_batches = DataLoader(
-        fo_valid_set, config.batch_size, shuffle=False, drop_last=False
-    )
-
-    print("running predictions on the data")
-
-    correct = 0
-    total = 0
-    class_correct = list(0.0 for i in range(10))
-    class_total = list(0.0 for i in range(10))
-    with torch.no_grad():
-        for images, labels, ids in valid_batches.dataloader:
-            y = model(images.cuda().half())
-            _, predicted = torch.max(y, 1)
-            total += labels.size(0)
-            labels_gpu = labels.cuda().half()
-            correct += (predicted == labels_gpu).sum().item()
-            c = (predicted == labels_gpu).squeeze()
-            for i in range(min(config.batch_size, len(labels))):
-                label = labels[i]
-                class_correct[label] += c[i].item()
-                class_total[label] += 1
-
-            for prediction, theid, thelogit in zip(predicted, ids, y):
-                label = fo.Classification(
-                    label=cifar10_classes[prediction], logits=thelogit
-                )
-                dataset[theid]["prediction"] = label
-                dataset[theid]["max-logit"] = float(
-                    np.max(thelogit.cpu().numpy())
-                )
-
-    print(
-        "Accuracy of the network on the 10K test images: %.2f%%"
-        % (100 * correct / total)
-    )
-
-    for i in range(10):
-        print(
-            "Accuracy of %9s : %.2f%%"
-            % (cifar10_classes[i], 100 * class_correct[i] / class_total[i])
-        )
-
-    print("done")
-
-    return dataset
-
-
-if __name__ == "__main__":
-
-    config = commandline()
-
-    print(f"running with config: {config}")
-
-    dataset = main(config)