Repository: davidlmorton/pytorch-sconce (language: python)

Issue:
Rename data generators to data feeds
also rename test_data to validation_data as per definitions here:

https://en.wikipedia.org/wiki/Training,_test,_and_validation_sets


Discussion / hints:

Fix patch:
diff --git a/sconce/data_feeds/__init__.py b/sconce/data_feeds/__init__.py
new file mode 100644
index 0000000..94c3b6c
--- /dev/null
+++ b/sconce/data_feeds/__init__.py
@@ -0,0 +1,12 @@
+# flake8: noqa
+from .base import DataFeed
+from .image import ImageFeed
+from .multi_class_image import MultiClassImageFeed
+from .single_class_image import SingleClassImageFeed
+
+def print_deprecation_warning(argument_prefix='', new_argument_prefix=None):
+    if new_argument_prefix is None:
+        new_argument_prefix = argument_prefix
+
+    print(f"WARNING: The {argument_prefix}data_generator argument is deprecated as of 1.2.0, and will "
+          f"be removed soon.  Please use {new_argument_prefix}feed instead.")
diff --git a/sconce/data_feeds/base.py b/sconce/data_feeds/base.py
new file mode 100644
index 0000000..7458612
--- /dev/null
+++ b/sconce/data_feeds/base.py
@@ -0,0 +1,165 @@
+from sconce.datasets import Subset
+from torch.utils import data
+
+import copy
+import numpy as np
+import torch
+
+
+class DataFeed:
+    """
+    A thin wrapper around a :py:class:`~torch.utils.data.DataLoader` that
+    automatically yields tuples of :py:class:`torch.Tensor` (that
+    live on cpu or on cuda).
+    A DataFeed will iterate endlessly.
+
+    Like the underlying :py:class:`~torch.utils.data.DataLoader`, a
+    DataFeed's ``__next__`` method yields two values, which we refer to as
+    the `inputs` and the `targets`.
+
+    Arguments:
+        data_loader (:py:class:`~torch.utils.data.DataLoader`): the wrapped
+            data_loader.
+    """
+
+    def __init__(self, data_loader):
+        self.data_loader = data_loader
+        self._inputs_cuda = False
+        self._targets_cuda = False
+        self.reset()
+
+    def cuda(self, device=None):
+        """
+        Put the `inputs` and `targets` (yielded by this DataFeed) on the
+        specified device.
+
+        Arguments:
+            device (int or bool or dict): if int or bool, sets the behavior for
+                both `inputs` and `targets`. To set them individually, pass a
+                dictionary with keys {'inputs', 'targets'} instead. See
+                :py:meth:`torch.Tensor.cuda` for details.
+
+        Example:
+            >>> g = DataFeed.from_dataset(dataset, batch_size=100)
+            >>> g.cuda()
+            >>> g.next()
+            (Tensor containing:
+             [torch.cuda.FloatTensor of size 100x1x28x28 (GPU 0)],
+             Tensor containing:
+             [torch.cuda.LongTensor of size 100 (GPU 0)])
+            >>> g.cuda(False)
+            >>> g.next()
+            (Tensor containing:
+             [torch.FloatTensor of size 100x1x28x28],
+             Tensor containing:
+             [torch.LongTensor of size 100])
+            >>> g.cuda(device={'inputs':0, 'targets':1})
+            >>> g.next()
+            (Tensor containing:
+             [torch.cuda.FloatTensor of size 100x1x28x28 (GPU 0)],
+             Tensor containing:
+             [torch.cuda.LongTensor of size 100 (GPU 1)])
+        """
+        if isinstance(device, dict):
+            for key, value in device.items():
+                if key == 'inputs':
+                    self._inputs_cuda = value
+                elif key == 'targets':
+                    self._targets_cuda = value
+                else:
+                    raise RuntimeError(f"Invalid key for 'device' argument: "
+                            f"({key}) expected to be in ('inputs', 'targets').")
+        else:
+            self._inputs_cuda = device
+            self._targets_cuda = device
+
+    def reset(self):
+        """
+        Start iterating through the data_loader from the begining.
+        """
+        self._iterator = iter(self.data_loader)
+
+    @property
+    def dataset(self):
+        """
+        the wrapped data_loader's :py:class:`~torch.utils.data.Dataset`
+        """
+        return self.data_loader.dataset
+
+    @property
+    def batch_size(self):
+        """
+        the wrapped data_loader's batch_size
+        """
+        return self.data_loader.batch_size
+
+    @property
+    def num_samples(self):
+        """
+        the ``len`` of the wrapped data_loader's
+        :py:class:`~torch.utils.data.Dataset`
+        """
+        return len(self.dataset)
+
+    def __len__(self):
+        return len(self.data_loader)
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        return self.next()
+
+    def preprocess(self, inputs, targets):
+        if self._inputs_cuda is False:
+            inputs = inputs.cpu()
+        else:
+            inputs = inputs.cuda(self._inputs_cuda)
+
+        if self._targets_cuda is False:
+            if isinstance(targets, torch.Tensor):
+                targets = targets.cpu()
+        else:
+            targets = targets.cuda(self._targets_cuda)
+
+        return inputs, targets
+
+    def next(self):
+        try:
+            inputs, targets = self._iterator.next()
+        except StopIteration:
+            self.reset()
+            inputs, targets = self._iterator.next()
+        return self.preprocess(inputs, targets)
+
+    @classmethod
+    def from_dataset(cls, dataset, split=None, **kwargs):
+        """
+        Create a DataFeed from an instantiated dataset.
+
+        Arguments:
+            dataset (:py:class:`~torch.utils.data.Dataset`): the pytorch
+                dataset.
+            split (float, optional): If not ``None``, it specifies the fraction of the dataset that should be placed
+                into the first of two data_feeds.  The remaining data is used for the second data_feed.  Both
+                data_feeds will be returned.
+            **kwargs: passed directly to the
+                :py:class:`~torch.utils.data.DataLoader`) constructor.
+        """
+        if split is not None:
+            num_samples = len(dataset)
+            indices = np.arange(0, num_samples)
+            np.random.shuffle(indices)
+
+            dataset1 = dataset
+            dataset2 = copy.copy(dataset)
+
+            subset1 = Subset(dataset1, indices=indices[:int(num_samples * split)])
+            subset2 = Subset(dataset2, indices=indices[int(num_samples * split):])
+
+            loader1 = data.DataLoader(subset1, **kwargs)
+            loader2 = data.DataLoader(subset2, **kwargs)
+            return cls(loader1), cls(loader2)
+        else:
+            data_loader = data.DataLoader(dataset, **kwargs)
+            return cls(data_loader)
diff --git a/sconce/data_feeds/image.py b/sconce/data_feeds/image.py
new file mode 100644
index 0000000..5d424c8
--- /dev/null
+++ b/sconce/data_feeds/image.py
@@ -0,0 +1,139 @@
+from matplotlib import pyplot as plt
+from sconce.data_feeds.base import DataFeed
+from torchvision import transforms
+
+import pandas as pd
+import seaborn as sns
+
+
+class ImageFeed(DataFeed):
+    """
+    A Datafeed with some handy methods for image type data.
+    """
+    @property
+    def num_channels(self):
+        """
+        The number of image channels, based on looking at the first image in the dataset.
+        """
+        dataset = self.dataset
+        for image, target in dataset:
+            return image.shape[0]
+
+    def get_image_size_df(self):
+        """
+        Return a pandas dataframe that contains the image sizes in the dataset (before transforms).
+        """
+        if not hasattr(self, '_image_size_df'):
+            self._image_size_df = self._get_image_size_df()
+        return self._image_size_df
+
+    def _get_image_size_df(self):
+        info_list = []
+
+        dataset = self.dataset
+
+        old_transform = dataset.transform
+        try:
+            dataset.transform = None
+            for image, _ in dataset:
+                info = {'height': image.height, 'width': image.width}
+                info_list.append(info)
+        except Exception as e:
+            print(e)
+            pass
+        dataset.transform = old_transform
+
+        return pd.DataFrame(info_list)
+
+    def get_class_df(self):
+        """
+        Return a pandas dataframe that contains the classes in the dataset.
+        """
+        if not hasattr(self, '_class_df'):
+            self._class_df = self._get_class_df()
+        return self._class_df
+
+    def _get_class_df(self):
+        dataset = self.dataset
+        rows = [{'unclassified': True} for t in dataset.targets]
+        return pd.DataFrame(rows)
+
+    def plot_class_summary(self, **kwargs):
+        """
+        Generate a barchart showing how many images of each class there are.
+        """
+        df = self.get_class_df()
+        plot_kwargs = {'kind': 'bar', **kwargs}
+        return df.sum().plot(**plot_kwargs)
+
+    def plot_image_size_summary(self):
+        """
+        Generate a scatter plot showing the sizes of the images in the dataset.
+        """
+        df = self.get_image_size_df()
+        return sns.jointplot(x="height", y="width",
+                kind='scatter', stat_func=None, data=df)
+
+    def _get_original_image(self, index):
+        dataset = self.dataset
+        old_transform = dataset.transform
+
+        dataset.transform = transforms.ToTensor()
+        image = self.dataset[index][0]
+
+        dataset.transform = old_transform
+        return image
+
+    def plot_transforms(self, index,
+            num_samples=5,
+            num_cols=5,
+            figure_width=15,
+            image_height=3,
+            return_fig=False):
+        """
+        Plot the same image from this Datafeed multiple times to see how the transforms affect them.
+
+        Arguments:
+            index (int): the index of the image to plot.
+            num_samples (int, optional): the number of times to plot the image (1 original, n - 1 transformed
+                variations).
+            num_cols (int, optional): the number of columns in the plot grid.
+            num_cols (int): the number of columns to plot, one image per column.
+            figure_width (float): the size, in matplotlib-inches, for the width of the whole figure.
+            image_height (float): the size, in matplotlib-inches, for the height of a single image.
+            return_fig (bool): return the generated matplotlib figure or not.
+        """
+
+        original = self._get_original_image(index)
+        samples = [original] + [self.dataset[index][0] for i in range(num_samples - 1)]
+
+        num_rows = -(-num_samples // num_cols)
+        fig = plt.figure(figsize=(figure_width, image_height * num_rows))
+
+        for i in range(num_samples):
+            image = samples[i].cpu().data.numpy()
+
+            if image.shape[0] == 1:
+                # greyscale image
+                image = image[0]
+                cmap = 'gray'
+            else:
+                # color channels present
+                image = image.swapaxes(0, 2)
+                image = image.swapaxes(0, 1)
+                cmap = None
+
+            ax = fig.add_subplot(num_rows, num_cols, i + 1)
+            ax.imshow(image, cmap=cmap)
+            if i != 0:
+                if i != 1:
+                    ax.axis('off')
+                ax.set_title('transformed')
+            else:
+                ax.set_title('original')
+
+        plt.tight_layout()
+        fig.subplots_adjust(wspace=0.05)
+
+        if return_fig:
+            return fig
diff --git a/sconce/data_feeds/multi_class_image.py b/sconce/data_feeds/multi_class_image.py
new file mode 100644
index 0000000..12aa9fa
--- /dev/null
+++ b/sconce/data_feeds/multi_class_image.py
@@ -0,0 +1,23 @@
+from sconce.data_feeds import ImageFeed
+
+import pandas as pd
+
+
+class MultiClassImageFeed(ImageFeed):
+    """
+    An ImageFeed class for use when each image may belong to more than one class.
+    """
+    def _get_class_df(self, dataset=None):
+        if dataset is None:
+            dataset = self.dataset
+
+        rows = []
+
+        for target in dataset.targets:
+            row = {_class: False for _class in dataset.classes}
+            for idx in target:
+                _class = dataset.classes[idx]
+                row[_class] = True
+            rows.append(row)
+
+        return pd.DataFrame(rows)
diff --git a/sconce/data_feeds/single_class_image.py b/sconce/data_feeds/single_class_image.py
new file mode 100644
index 0000000..6f6f014
--- /dev/null
+++ b/sconce/data_feeds/single_class_image.py
@@ -0,0 +1,103 @@
+from sconce.data_feeds import ImageFeed
+from sconce.datasets import Subset
+from torchvision import datasets, transforms
+
+import numpy as np
+import os
+import pandas as pd
+import tempfile
+
+
+class SingleClassImageFeed(ImageFeed):
+    """
+    An ImageFeed class for use when each image belongs to exactly one class.
+    """
+    def _get_class_df(self):
+        dataset = self.dataset
+        rows = []
+
+        for target in dataset.targets:
+            row = {}
+            for _class in dataset.classes:
+                idx = dataset.class_to_idx[_class]
+                if target == idx:
+                    row[_class] = True
+                else:
+                    row[_class] = False
+            rows.append(row)
+        return pd.DataFrame(rows)
+
+    @classmethod
+    def from_torchvision(cls,
+            batch_size=500,
+            data_location=None,
+            dataset_class=datasets.MNIST,
+            fraction=1.0,
+            num_workers=0,
+            pin_memory=True,
+            shuffle=True,
+            train=True,
+            transform=transforms.ToTensor()):
+        """
+        Create a Datafeed from a torchvision dataset class.
+
+        Arguments:
+            batch_size (int): how large the yielded `inputs` and `targets`
+                should be. See :py:class:`DataLoader` for details.
+            data_location (path): where downloaded dataset should be stored.  If
+                ``None`` a system dependent temporary location will be used.
+            dataset_class (class): a torchvision dataset class that supports
+                constructor arguments {'root', 'train', 'download',
+                'transform'}. For example, MNIST, FashionMnist, CIFAR10, or
+                CIFAR100.
+            fraction (float): (0.0 - 1.0] how much of the original dataset's
+                data to use.
+            num_workers (int): how many subprocesses to use for data loading.
+                See :py:class:`DataLoader` for details.
+            pin_memory (bool): if ``True``, the data loader will copy tensors
+                into CUDA pinned memory before returning them. See
+                :py:class:`DataLoader` for details.
+            shuffle (bool): set to ``True`` to have the data reshuffled at every
+                epoch. See :py:class:`DataLoader` for details.
+            train (bool): if ``True``, creates dataset from training set,
+                otherwise creates from test set.
+            transform (callable): a function/transform that takes in an PIL
+                image and returns a transformed version.
+        """
+        assert(fraction > 0.0)
+        assert(fraction <= 1.0)
+
+        if data_location is None:
+            data_location = os.path.join(tempfile.gettempdir(),
+                    dataset_class.__name__)
+
+        dataset = dataset_class(data_location,
+                train=train,
+                download=True,
+                transform=transform)
+        indices = [int(x) for x in np.linspace(
+                start=0,
+                stop=len(dataset) - 1,
+                num=int(len(dataset) * fraction))]
+        subset = Subset(dataset, indices=indices)
+        return cls.from_dataset(subset,
+                batch_size=batch_size,
+                num_workers=num_workers,
+                pin_memory=pin_memory,
+                shuffle=shuffle)
+
+    @classmethod
+    def from_image_folder(self, root, loader_kwargs=None, **dataset_kwargs):
+        """
+        Create a Datafeed from a folder of images.  See :py:class:`torchvision.datasets.ImageFolder`.
+
+        Arguments:
+            root (path): the root directory path.
+            loader_kwargs (dict): keyword args provided to the DataLoader constructor.
+            **dataset_kwargs: keyword args provided to the :py:class:`torchvision.datasets.ImageFolder` constructor.
+        """
+        if loader_kwargs is None:
+            loader_kwargs = {}
+
+        dataset = datasets.ImageFolder(root=root, **dataset_kwargs)
+        return self.from_dataset(dataset, **loader_kwargs)
diff --git a/sconce/data_generators/base.py b/sconce/data_generators/base.py
index e192ac0..dc182ba 100644
--- a/sconce/data_generators/base.py
+++ b/sconce/data_generators/base.py
@@ -20,9 +20,15 @@ class DataGenerator:
     Arguments:
         data_loader (:py:class:`~torch.utils.data.DataLoader`): the wrapped
             data_loader.
+
+    Note:
+        DataGenerators are deprecated as of 1.2.0 and will be removed soon.  Please use a
+        :py:class:`~sconce.data_feeds.base.DataFeed` instead.
     """
 
     def __init__(self, data_loader):
+        print("WARNING: DataGenerators are deprecated as of 1.2.0 and will be removed soon.  "
+              "Please use a DataFeed instead")
         self.data_loader = data_loader
         self._inputs_cuda = False
         self._targets_cuda = False
diff --git a/sconce/models/base.py b/sconce/models/base.py
index eaa208a..6985524 100644
--- a/sconce/models/base.py
+++ b/sconce/models/base.py
@@ -33,7 +33,7 @@ def add_parameter_group(self, group, inactivate_default=True):
         Add a new parameter group to this model.
 
         Arguments:
-            group (:py:class:`~sconce.parameter_group.ParameterGroup): the parameter group to add.
+            group (:py:class:`~sconce.parameter_group.ParameterGroup`): the parameter group to add.
             inactivate_default (bool, optional): if ``True``, then the default parameter group will have
                 ``is_active`` set to ``False``.
         """
diff --git a/sconce/monitors/base.py b/sconce/monitors/base.py
index 2c6abe9..268f75b 100644
--- a/sconce/monitors/base.py
+++ b/sconce/monitors/base.py
@@ -52,7 +52,7 @@ def write(self, data, step, **kwargs):
 
         Arguments:
             data (dict): the output of the training/evaluation step.  The keys
-                may include, but are not limited to: {'training_loss', 'test_loss',
+                may include, but are not limited to: {'training_loss', 'validation_loss',
                 'learning_rate'}.
             step (float): (0.0, inf) the step that was just completed.
                 Fractional steps are possible (see batch_multiplier option on
@@ -71,7 +71,7 @@ class CompositeMonitor(Monitor):
     method and have it use all of the composed monitors.  Composed monitors can be accessed using their name like so:
 
         >>> from sconce import monitors
-        >>> metric_names = {'training_loss': 'loss', 'test_loss': 'val_loss'}
+        >>> metric_names = {'training_loss': 'loss', 'validation_loss': 'val_loss'}
         >>> stdout_monitor = monitors.StdoutMonitor(metric_names=metric_names)
         >>> dataframe_monitor = monitors.DataframeMonitor()
         >>> monitor = dataframe_monitor + stdout_monitor
diff --git a/sconce/monitors/dataframe_monitor.py b/sconce/monitors/dataframe_monitor.py
index 8582cb2..226f65d 100644
--- a/sconce/monitors/dataframe_monitor.py
+++ b/sconce/monitors/dataframe_monitor.py
@@ -83,9 +83,16 @@ def plot(self, title="Training History", figsize=(15, 5),
                     skip_first=100, smooth_window=50,
                     metrics=['loss'],
                     hyperparameters=['learning_rate'],
-                    test_color='tomato',
+                    test_color=None,
+                    validation_color='tomato',
                     training_color='mediumseagreen',
                     fig=None):
+
+        if test_color is not None:
+            print("WARNING: The test_color argument is deprecated as of 1.2.0.  "
+                  "Please use validation_color instead.")
+            validation_color = test_color
+
         if fig is None:
             fig = plt.figure(figsize=figsize)
             num_rows = 2 + len(hyperparameters)
@@ -103,11 +110,11 @@ def plot(self, title="Training History", figsize=(15, 5),
             training_df.interpolate().plot(ax=metrics_ax,
                     color=training_color, label='', alpha=0.35)
 
-            test_key = 'test_%s' % metric
-            if test_key in df:
-                test_df = df[test_key]
-                test_df.interpolate().plot(ax=metrics_ax,
-                        color=test_color, label='', alpha=0.35)
+            val_key = 'validation_%s' % metric
+            if val_key in df:
+                val_df = df[val_key]
+                val_df.interpolate().plot(ax=metrics_ax,
+                        color=validation_color, label='', alpha=0.35)
 
             training_smooth_df = training_df.rolling(smooth_window,
                     min_periods=1).mean()
@@ -120,11 +127,11 @@ def plot(self, title="Training History", figsize=(15, 5),
                     color=training_color, path_effects=my_path_effects,
                     label='Training')
 
-            if test_key in df:
-                test_smooth_df = test_df.rolling(smooth_window,
+            if val_key in df:
+                val_smooth_df = val_df.rolling(smooth_window,
                         min_periods=1).mean()
-                test_smooth_df.interpolate().plot(ax=metrics_ax, linewidth=3,
-                        color=test_color, path_effects=my_path_effects,
+                val_smooth_df.interpolate().plot(ax=metrics_ax, linewidth=3,
+                        color=validation_color, path_effects=my_path_effects,
                         label='Test')
 
             metrics_ax.set_ylabel(stringcase.titlecase(metric))
diff --git a/sconce/monitors/losswise_monitor.py b/sconce/monitors/losswise_monitor.py
index ea180bd..bee15e3 100644
--- a/sconce/monitors/losswise_monitor.py
+++ b/sconce/monitors/losswise_monitor.py
@@ -10,7 +10,7 @@ def __init__(self, api_key, tag,
             min_graphs={
                 'loss': {
                     'training_loss': 'Training Loss',
-                    'test_loss': 'Test Loss',
+                    'validation_loss': 'Validation Loss',
                 },
                 'lr': {
                     'learning_rate': 'Learning Rate',
diff --git a/sconce/schedules/step.py b/sconce/schedules/step.py
index 4874f32..6203643 100644
--- a/sconce/schedules/step.py
+++ b/sconce/schedules/step.py
@@ -16,7 +16,7 @@ class Step(Schedule):
 
     Note:
         The parameter <num_steps> is set during training based on the size of the batch_size and number of samples in
-        the training_data_generator, and the batch_multiplier value.
+        the training_feed, and the batch_multiplier value.
     """
     def __init__(self, initial_value, final_value, num_changes=1):
         self.initial_value = initial_value
diff --git a/sconce/trainer.py b/sconce/trainer.py
index 5c58529..edd26b4 100644
--- a/sconce/trainer.py
+++ b/sconce/trainer.py
@@ -1,5 +1,7 @@
 from sconce import monitors, schedules
 from sconce.exceptions import StopTrainingError
+from sconce.data_feeds import print_deprecation_warning
+
 
 import copy
 import numpy as np
@@ -14,9 +16,13 @@ class Trainer:
     Keyword Arguments:
         model (:py:class:`~sconce.models.base.Model`): the sconce model to be trained.  See :py:mod:`sconce.models`
             for examples.
-        training_data_generator (:py:class:`~sconce.data_generators.base.DataGenerator`): yields training `inputs` and
+        training_data_generator (:py:class:`~sconce.data_generators.base.DataGenerator`): DEPRECATED, use
+            training_feed argument instead.
+        training_feed (:py:class:`~sconce.data_feed.base.DataFeed`): used during training to provide `inputs` and
             `targets`.
-        test_data_generator (:py:class:`~sconce.data_generators.base.DataGenerator`): yields test `inputs` and
+        test_data_generator (:py:class:`~sconce.data_generators.base.DataGenerator`): DEPRECATED, use
+            validation_feed argument instead.
+        validation_feed (:py:class:`~sconce.data_feed.base.DataFeed`) used during validation to provide `inputs` and
             `targets`.  These are never used for back-propagation.
         monitor (:py:class:`~sconce.monitors.base.Monitor`, optional): the sconce monitor that records data during
             training.  This data can be sent to external systems during training or kept until training completes
@@ -24,25 +30,40 @@ class Trainer:
             :py:class:`~sconce.monitors.stdout_monitor.StdoutMonitor` and a
             :py:class:`~sconce.monitors.dataframe_monitor.DataframeMonitor` will be created for you and used.
     """
-    def __init__(self, *, model, test_data_generator, training_data_generator, monitor=None):
+    def __init__(self, *, model, validation_feed=None, training_feed=None,
+            test_data_generator=None, training_data_generator=None, monitor=None):
         self.model = model
-        self.test_data_generator = test_data_generator
-        self.training_data_generator = training_data_generator
+
+        if test_data_generator is None and validation_feed is None:
+            raise TypeError("Missing required keyword argument: 'validation_feed'")
+
+        if test_data_generator is not None:
+            print_deprecation_warning('test_', 'validation_')
+            validation_feed = test_data_generator
+
+        if training_data_generator is None and training_feed is None:
+            raise TypeError("Missing required keyword argument: 'training_feed'")
+
+        if training_data_generator is not None:
+            print_deprecation_warning('training_')
+            training_feed = training_data_generator
+
+        self.validation_feed = validation_feed
+        self.training_feed = training_feed
 
         if monitor is None:
-            metric_names = {'training_loss': 'loss', 'test_loss': 'val_loss'}
+            metric_names = {'training_loss': 'loss', 'validation_loss': 'val_loss'}
             stdout_monitor = monitors.StdoutMonitor(metric_names=metric_names)
             monitor = monitors.DataframeMonitor() + stdout_monitor
         self.monitor = monitor
 
-        self.test_to_train_ratio = (len(test_data_generator) /
-                                    len(training_data_generator))
+        self.validation_to_train_ratio = (len(validation_feed) / len(training_feed))
 
         self.checkpoint_filename = None
         self._reset_cache()
 
     def _reset_cache(self):
-        self._cache_data_generator = None
+        self._cache_feed = None
         self._inputs = None
         self._targets = None
         self._outputs = None
@@ -95,6 +116,7 @@ def load_model_state(self, filename):
 
     def train(self, *, num_epochs, monitor=None,
             test_to_train_ratio=None,
+            validation_to_train_ratio=None,
             batch_multiplier=1):
         """
         Train the model for a given number of epochs.
@@ -118,25 +140,35 @@ def train(self, *, num_epochs, monitor=None,
         assert batch_multiplier > 0
         assert int(batch_multiplier) == batch_multiplier
 
+        if test_to_train_ratio is not None:
+            print("WARNING: The test_to_train_ratio argument is deprecated as of 1.2.0.  "
+                  "Please use validation_to_train_ratio instead.")
+            validation_to_train_ratio = test_to_train_ratio
+
         if monitor is None:
             monitor = self.monitor
-        if test_to_train_ratio is None:
-            test_to_train_ratio = self.test_to_train_ratio
+        if validation_to_train_ratio is None:
+            validation_to_train_ratio = self.validation_to_train_ratio
 
         num_steps = self.get_num_steps(num_epochs=num_epochs,
-                data_generator=self.training_data_generator,
+                feed=self.training_feed,
                 batch_multiplier=batch_multiplier)
 
         return self._train(num_steps=num_steps,
                 monitor=monitor,
-                test_to_train_ratio=test_to_train_ratio,
+                validation_to_train_ratio=validation_to_train_ratio,
                 batch_multiplier=batch_multiplier)
 
-    def get_num_steps(self, num_epochs, data_generator=None, batch_multiplier=1):
-        if data_generator is None:
-            data_generator = self.training_data_generator
-        num_samples = num_epochs * data_generator.num_samples
-        batch_size = data_generator.batch_size
+    def get_num_steps(self, num_epochs, data_generator=None, feed=None, batch_multiplier=1):
+        if data_generator is not None:
+            print_deprecation_warning()
+            feed = data_generator
+
+        if feed is None:
+            feed = self.training_feed
+
+        num_samples = num_epochs * feed.num_samples
+        batch_size = feed.batch_size
         effective_batch_size = batch_size * batch_multiplier
         num_steps = int(num_samples / effective_batch_size)
 
@@ -145,7 +177,7 @@ def get_num_steps(self, num_epochs, data_generator=None, batch_multiplier=1):
         else:
             return num_steps
 
-    def _train(self, *, num_steps, monitor, test_to_train_ratio, batch_multiplier):
+    def _train(self, *, num_steps, monitor, validation_to_train_ratio, batch_multiplier):
         self._reset_cache()
         monitor.start_session(num_steps)
         self.model.start_session(num_steps)
@@ -165,7 +197,7 @@ def _train(self, *, num_steps, monitor, test_to_train_ratio, batch_multiplier):
                 optimizer.zero_grad()
 
             for i in range(1, batch_multiplier + 1):
-                inputs, targets = self.training_data_generator.next()
+                inputs, targets = self.training_feed.next()
                 step_dict = self._do_step(inputs, targets, train=True)
 
                 loss = step_dict['loss'] / batch_multiplier
@@ -175,11 +207,11 @@ def _train(self, *, num_steps, monitor, test_to_train_ratio, batch_multiplier):
                         for k, v in step_dict.items()}
 
                 iterations_since_test += 1
-                if (1 / iterations_since_test) <= test_to_train_ratio:
-                    test_step_dict = self._do_test_step()
+                if (1 / iterations_since_test) <= validation_to_train_ratio:
+                    validation_step_dict = self._do_validation_step()
                     iterations_since_test = 0
 
-                    current_state.update({**training_step_dict, **test_step_dict})
+                    current_state.update({**training_step_dict, **validation_step_dict})
                 else:
                     current_state.update(training_step_dict)
 
@@ -219,9 +251,9 @@ def _run_model(self, inputs, targets, train):
         out_dict = self.model(**in_dict)
         return {**out_dict, **in_dict}
 
-    def _run_model_on_generator(self, data_generator,
+    def _run_model_on_feed(self, feed,
             cache_results=True):
-        if self._cache_data_generator is data_generator:
+        if feed is self._cache_feed:
             return {'inputs': self._inputs,
                     'targets': self._targets,
                     'outputs': self._outputs}
@@ -230,9 +262,9 @@ def _run_model_on_generator(self, data_generator,
         targets = []
         outputs = []
 
-        data_generator.reset()
-        for x in range(len(data_generator)):
-            i, t = data_generator.next()
+        feed.reset()
+        for x in range(len(feed)):
+            i, t = feed.next()
             out_dict = self._run_model(i, t, train=False)
 
             inputs.append(i.cpu().data.numpy())
@@ -244,7 +276,7 @@ def _run_model_on_generator(self, data_generator,
         outputs = np.concatenate(outputs)
 
         if cache_results:
-            self._cache_data_generator = data_generator
+            self._cache_feed = feed
             self._inputs = inputs
             self._targets = targets
             self._outputs = outputs
@@ -253,14 +285,34 @@ def _run_model_on_generator(self, data_generator,
                 'targets': targets,
                 'outputs': outputs}
 
-    def _do_test_step(self):
-        inputs, targets = self.test_data_generator.next()
+    def _do_validation_step(self):
+        inputs, targets = self.validation_feed.next()
         step_dict = self._do_step(inputs, targets, train=False)
-        return {f'test_{k}': v for k, v in step_dict.items()}
+        return {f'validation_{k}': v for k, v in step_dict.items()}
 
     def test(self, *, monitor=None):
         """
-        Run all samples of self.test_data_generator through the model in test (inference) mode.
+        Run all samples of self.validation_feed through the model in test (inference) mode.
+
+        Arguments:
+            monitor (:py:class:`~sconce.monitors.base.Monitor`, optional): the sconce monitor that records data during
+                this testing.  If ``None``, a composite monitor consisting of a
+                :py:class:`~sconce.monitors.stdout_monitor.StdoutMonitor` and a
+                :py:class:`~sconce.monitors.dataframe_monitor.DataframeMonitor` will be created for you and used.
+
+        Returns:
+            monitor (:py:class:`~sconce.monitors.base.Monitor`): the monitor used during this testing.
+
+        Note:
+            This method has been deprecated since 1.2.0.  Please use the ``validate()`` method instead.
+        """
+        print("WARNING: Trainer.test() is deprecated as of 1.2.0.  "
+              "Please use Trainer.validate() instead.")
+        return self.validate(monitor=monitor)
+
+    def validate(self, *, monitor=None):
+        """
+        Run all samples of self.validation_feed through the model in test (inference) mode.
 
         Arguments:
             monitor (:py:class:`~sconce.monitors.base.Monitor`, optional): the sconce monitor that records data during
@@ -272,15 +324,15 @@ def test(self, *, monitor=None):
             monitor (:py:class:`~sconce.monitors.base.Monitor`): the monitor used during this testing.
         """
         if monitor is None:
-            metric_names = {'test_loss': 'loss'}
+            metric_names = {'validation_loss': 'val_loss'}
             stdout_monitor = monitors.StdoutMonitor(metric_names=metric_names)
             monitor = monitors.DataframeMonitor() + stdout_monitor
 
-        num_steps = len(self.test_data_generator)
+        num_steps = len(self.validation_feed)
         monitor.start_session(num_steps)
 
         for step in range(1, num_steps + 1):
-            step_data = self._do_test_step()
+            step_data = self._do_validation_step()
 
             monitor.write(data=step_data, step=step)
         monitor.end_session()
@@ -352,7 +404,7 @@ def survey_learning_rate(self, *, num_epochs=1.0,
 
         self.train(num_epochs=num_epochs,
                 monitor=monitor,
-                test_to_train_ratio=0,
+                validation_to_train_ratio=0,
                 batch_multiplier=batch_multiplier)
 
         self.model.load_state_dict(orig_model_state_dict)
diff --git a/sconce/trainers/autoencoder_trainer.py b/sconce/trainers/autoencoder_trainer.py
index 90d4a66..ac72de0 100644
--- a/sconce/trainers/autoencoder_trainer.py
+++ b/sconce/trainers/autoencoder_trainer.py
@@ -10,7 +10,7 @@
 class AutoencoderMixin(ABC):
     def plot_input_output_pairs(self, title='A Sampling of Autoencoder Results',
         num_cols=10, figsize=(15, 3.2)):
-        inputs, targets = self.test_data_generator.next()
+        inputs, targets = self.validation_feed.next()
         out_dict = self._run_model(inputs, targets, train=True)
         outputs = out_dict['outputs']
 
@@ -41,9 +41,9 @@ def plot_latent_space(self, title="Latent Representation", figsize=(8, 8)):
         fig.suptitle(title, fontsize=20)
 
         self.model.train(False)
-        self.test_data_generator.reset()
-        for i in range(len(self.test_data_generator)):
-            inputs, targets = self.test_data_generator.next()
+        self.validation_feed.reset()
+        for i in range(len(self.validation_feed)):
+            inputs, targets = self.validation_feed.next()
             x_latent = self.model.encode(inputs=inputs, targets=targets)
 
             x_latent_numpy = x_latent.cpu().data.numpy()
diff --git a/sconce/trainers/single_class_image_classifier_trainer.py b/sconce/trainers/single_class_image_classifier_trainer.py
index 50823de..cb360bd 100644
--- a/sconce/trainers/single_class_image_classifier_trainer.py
+++ b/sconce/trainers/single_class_image_classifier_trainer.py
@@ -1,7 +1,8 @@
 from abc import ABC
+from matplotlib import pyplot as plt
 from scipy import sparse
+from sconce.data_feeds import print_deprecation_warning
 from sconce.trainer import Trainer
-from matplotlib import pyplot as plt
 
 import seaborn as sn
 import numpy as np
@@ -11,12 +12,15 @@
 
 
 class SingleClassImageClassifierMixin(ABC):
-    def get_confusion_matrix(self, data_generator=None, cache_results=True):
-        if data_generator is None:
-            data_generator = self.test_data_generator
+    def get_confusion_matrix(self, data_generator=None, feed=None, cache_results=True):
+        if data_generator is not None:
+            print_deprecation_warning()
+            feed = data_generator
 
-        run_model_results = self._run_model_on_generator(data_generator,
-                cache_results=cache_results)
+        if feed is None:
+            feed = self.validation_feed
+
+        run_model_results = self._run_model_on_feed(feed, cache_results=cache_results)
 
         targets = run_model_results['targets']
         predicted_targets = np.argmax(run_model_results['outputs'], axis=1)
@@ -24,23 +28,30 @@ def get_confusion_matrix(self, data_generator=None, cache_results=True):
                 (predicted_targets, targets)), dtype='uint32').toarray()
         return matrix
 
-    def get_classification_accuracy(self, data_generator=None,
+    def get_classification_accuracy(self, data_generator=None, feed=None,
             cache_results=True):
-        if data_generator is None:
-            data_generator = self.test_data_generator
+        if data_generator is not None:
+            print_deprecation_warning()
+            feed = data_generator
 
-        matrix = self.get_confusion_matrix(data_generator=data_generator,
-                cache_results=cache_results)
+        if feed is None:
+            feed = self.validation_feed
+
+        matrix = self.get_confusion_matrix(feed=feed, cache_results=cache_results)
         num_correct = np.trace(matrix)
-        return num_correct / data_generator.num_samples
+        return num_correct / feed.num_samples
 
-    def plot_confusion_matrix(self, data_generator=None, **heatmap_kwargs):
-        if data_generator is None:
-            data_generator = self.test_data_generator
+    def plot_confusion_matrix(self, data_generator=None, feed=None, **heatmap_kwargs):
+        if data_generator is not None:
+            print_deprecation_warning()
+            feed = data_generator
 
-        matrix = self.get_confusion_matrix(data_generator=data_generator)
+        if feed is None:
+            feed = self.validation_feed
 
-        dataset = data_generator.dataset
+        matrix = self.get_confusion_matrix(feed=feed)
+
+        dataset = feed.dataset
         defaults = {'cmap': 'YlGnBu', 'annot': True, 'fmt': 'd',
                     'xticklabels': dataset.classes,
                     'yticklabels': dataset.classes}
@@ -56,6 +67,7 @@ def plot_confusion_matrix(self, data_generator=None, **heatmap_kwargs):
     def plot_samples(self, predicted_class,
             true_class=None,
             data_generator=None,
+            feed=None,
             sort_by='rising predicted class score',
             num_samples=7,
             num_cols=7,
@@ -68,8 +80,9 @@ def plot_samples(self, predicted_class,
         Arguments:
             predicted_class (int or string): the class string or the index of the class that was predicted by the model.
             true_class (int or string): the class string or the index of the class that the image actually belongs to.
-            data_generator (:py:class:`~sconce.data_generators.base.SingleClassImageDataGenerator`): the data generator
-                to use to find the samples.
+            data_generator (:py:class:`~sconce.data_generators.base.SingleClassImageDataGenerator`): DEPRECATED,
+                use feed argument instead.
+            feed (:py:class:`~sconce.data_feeds.base.DataFeed`): the DataFeed to use to find the samples.
             sort_by (string): one of the sort_by strings, see note below.
             num_samples (int): the number of sample images to plot.
             num_cols (int): the number of columns to plot, one image per column.
@@ -89,14 +102,18 @@ def plot_samples(self, predicted_class,
                 - "falling true class score": samples are plotted in order of the higest true class score to
                   the lowest true class score.
         """
-        if data_generator is None:
-            data_generator = self.test_data_generator
+        if data_generator is not None:
+            print_deprecation_warning()
+            feed = data_generator
+
+        if feed is None:
+            feed = self.validation_feed
 
-        dataset = data_generator.dataset
+        dataset = feed.dataset
         predicted_class = self._convert_to_class_index(predicted_class, dataset)
         true_class = self._convert_to_class_index(true_class, dataset, default=predicted_class)
 
-        run_model_results = self._run_model_on_generator(data_generator,
+        run_model_results = self._run_model_on_feed(feed,
                 cache_results=cache_results)
 
         images = run_model_results['inputs']
diff --git a/sconce/utils.py b/sconce/utils.py
index c031f2b..7e4ceef 100644
--- a/sconce/utils.py
+++ b/sconce/utils.py
@@ -12,15 +12,17 @@
 
 class Progbar(object):
     """Displays a progress bar.
-    # Arguments
-        target: Total number of steps expected, None if unknown.
-        width: Progress bar width on screen.
-        verbose: Verbosity mode, 0 (silent), 1 (verbose), 2 (semi-verbose)
-        stateful_metrics: Iterable of string names of metrics that
+
+    Arguments:
+        target (int): Total number of steps expected, None if unknown.
+        width (float): Progress bar width on screen.
+        verbose (int): Verbosity mode, 0 (silent), 1 (verbose), 2 (semi-verbose)
+        stateful_metrics (list string): Iterable of string names of metrics that
             should *not* be averaged over time. Metrics in this list
             will be displayed as-is. All others will be averaged
             by the progbar before display.
-        interval: Minimum visual progress update interval (in seconds).
+        interval (float): Minimum visual progress update interval (in seconds).
+        alpha (float): The coefficient for exponentially weighted moving averages.
     """
 
     def __init__(self, target, width=30, verbose=1, interval=0.05,
@@ -48,13 +50,14 @@ def __init__(self, target, width=30, verbose=1, interval=0.05,
 
     def update(self, current, values=None):
         """Updates the progress bar.
-        # Arguments
-            current: Index of current step.
-            values: List of tuples:
-                `(name, value_for_last_step)`.
-                If `name` is in `stateful_metrics`,
-                `value_for_last_step` will be displayed as-is.
-                Else, an average of the metric over time will be displayed.
+
+        Arguments:
+            current (int): Index of current step.
+            values (list tuples): List of tuples like ``(name, value_for_last_step)``.
+
+        Note:
+            If ``name`` is in ``stateful_metrics``, ``value_for_last_step`` will be displayed as-is.
+            Else, an average of the metric over time will be displayed.
         """
         values = values or []
         for k, v in values:
diff --git a/sphinx/source/data_feeds.rst b/sphinx/source/data_feeds.rst
new file mode 100644
index 0000000..47a3e46
--- /dev/null
+++ b/sphinx/source/data_feeds.rst
@@ -0,0 +1,37 @@
+.. role:: hidden
+    :class: hidden-section
+
+
+Data Feeds
+==========
+
+.. currentmodule:: sconce.data_feeds
+
+
+:hidden:`DataFeed`
+~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: DataFeed
+    :members:
+
+:hidden:`ImageFeed`
+~~~~~~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: ImageFeed
+
+:hidden:`SingleClassImageFeed`
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: SingleClassImageFeed
+    :members:
+    :show-inheritance:
+    :inherited-members:
+
+
+:hidden:`MultiClassImageFeed`
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: MultiClassImageFeed
+    :members:
+    :show-inheritance:
+    :inherited-members:
diff --git a/sphinx/source/data_generator.rst b/sphinx/source/data_generator.rst
index 05b8b1e..4d97015 100644
--- a/sphinx/source/data_generator.rst
+++ b/sphinx/source/data_generator.rst
@@ -2,25 +2,25 @@
     :class: hidden-section
 
 
-Data Generators
-===============
+Data Generators (deprecated)
+============================
 
 .. currentmodule:: sconce.data_generators
 
 
-:hidden:`DataGenerator`
-~~~~~~~~~~~~~~~~~~~~~~~
+:hidden:`DataGenerator (deprecated)`
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: DataGenerator
     :members:
 
 :hidden:`ImageDataGenerator (deprecated)`
-~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: ImageDataGenerator
 
-:hidden:`SingleClassImageDataGenerator`
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+:hidden:`SingleClassImageDataGenerator (deprecated)`
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: SingleClassImageDataGenerator
     :members:
@@ -28,8 +28,8 @@ Data Generators
     :inherited-members:
 
 
-:hidden:`MultiClassImageDataGenerator`
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+:hidden:`MultiClassImageDataGenerator (deprecated)`
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: MultiClassImageDataGenerator
     :members:
diff --git a/sphinx/source/datasets.rst b/sphinx/source/datasets.rst
index 4e5ac5d..38392e7 100644
--- a/sphinx/source/datasets.rst
+++ b/sphinx/source/datasets.rst
@@ -9,7 +9,7 @@ Datasests
 
 
 :hidden:`CsvImageFolder`
-~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: CsvImageFolder
     :members:
diff --git a/sphinx/source/index.rst b/sphinx/source/index.rst
index b7d124b..9261e47 100644
--- a/sphinx/source/index.rst
+++ b/sphinx/source/index.rst
@@ -13,6 +13,7 @@ Sconce Documentation
    :caption: Class Reference
 
    data_generator
+   data_feeds
    datasets
    transforms
    monitors
diff --git a/sphinx/source/models.rst b/sphinx/source/models.rst
index 8052e96..fcfe701 100644
--- a/sphinx/source/models.rst
+++ b/sphinx/source/models.rst
@@ -8,7 +8,7 @@ Models
 .. automodule:: sconce.models
 
 :hidden:`Model`
-~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~
 
 .. autoclass:: Model
     :members:
@@ -20,7 +20,7 @@ Models
     :members:
 
 :hidden:`BasicConvolutionalAutoencoder`
-~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: BasicConvolutionalAutoencoder
     :members:
diff --git a/sphinx/source/parameter_group.rst b/sphinx/source/parameter_group.rst
index 6f9dbf8..5435f16 100644
--- a/sphinx/source/parameter_group.rst
+++ b/sphinx/source/parameter_group.rst
@@ -8,7 +8,7 @@ Parameter Group
 
 
 :hidden:`ParameterGroup`
-~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. autoclass:: ParameterGroup
     :members:
diff --git a/sphinx/source/sconce.data_feeds.rst b/sphinx/source/sconce.data_feeds.rst
new file mode 100644
index 0000000..dabc76d
--- /dev/null
+++ b/sphinx/source/sconce.data_feeds.rst
@@ -0,0 +1,31 @@
+sconce.data_feeds package
+=========================
+
+sconce.data_feeds.base module
+-----------------------------
+
+.. automodule:: sconce.data_feeds.base
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+sconce.data_feeds.image module
+------------------------------
+
+.. automodule:: sconce.data_feeds.image
+
+sconce.data_feeds.single_class_image module
+-------------------------------------------
+
+.. automodule:: sconce.data_feeds.single_class_image
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+sconce.data_feeds.multi_class_image module
+------------------------------------------
+
+.. automodule:: sconce.data_feeds.multi_class_image
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/sphinx/source/sconce.data_generators.rst b/sphinx/source/sconce.data_generators.rst
index a72a445..7feae10 100644
--- a/sphinx/source/sconce.data_generators.rst
+++ b/sphinx/source/sconce.data_generators.rst
@@ -1,8 +1,8 @@
-sconce.data_generators package
-==============================
+sconce.data_generators package (deprecated)
+===========================================
 
-sconce.data_generators.base module
-----------------------------------
+sconce.data_generators.base module (deprecated)
+-----------------------------------------------
 
 .. automodule:: sconce.data_generators.base
     :members:
@@ -14,24 +14,24 @@ sconce.data_generators.image_data_generator module (deprecated)
 
 .. automodule:: sconce.data_generators.image_data_generator
 
-sconce.data_generators.image_mixin module
------------------------------------------
+sconce.data_generators.image_mixin module (deprecated)
+------------------------------------------------------
 
 .. automodule:: sconce.data_generators.image_mixin
     :members:
     :undoc-members:
     :show-inheritance:
 
-sconce.data_generators.single_class_image_data_generator module
----------------------------------------------------------------
+sconce.data_generators.single_class_image_data_generator module (deprecated)
+----------------------------------------------------------------------------
 
 .. automodule:: sconce.data_generators.single_class_image_data_generator
     :members:
     :undoc-members:
     :show-inheritance:
 
-sconce.data_generators.multi_class_image_data_generator module
---------------------------------------------------------------
+sconce.data_generators.multi_class_image_data_generator module (deprecated)
+---------------------------------------------------------------------------
 
 .. automodule:: sconce.data_generators.multi_class_image_data_generator
     :members:
diff --git a/sphinx/source/sconce.rst b/sphinx/source/sconce.rst
index f3c9874..97c06e9 100644
--- a/sphinx/source/sconce.rst
+++ b/sphinx/source/sconce.rst
@@ -8,6 +8,7 @@ Subpackages
     :maxdepth: 1
 
     sconce.data_generators
+    sconce.data_feeds
     sconce.datasets
     sconce.monitors
     sconce.models
diff --git a/sphinx/source/sconce.schedules.rst b/sphinx/source/sconce.schedules.rst
index 17cd661..977cfcb 100644
--- a/sphinx/source/sconce.schedules.rst
+++ b/sphinx/source/sconce.schedules.rst
@@ -4,7 +4,7 @@ sconce.schedules package
 sconce.schedules.base module
 ------------------------------------
 
-.. automodule:: sconce.rate_controllers.base
+.. automodule:: sconce.schedules.base
     :members:
     :undoc-members:
     :show-inheritance:
diff --git a/sphinx/source/sconce.trainers.rst b/sphinx/source/sconce.trainers.rst
index 6c61143..943f8d6 100644
--- a/sphinx/source/sconce.trainers.rst
+++ b/sphinx/source/sconce.trainers.rst
@@ -16,8 +16,3 @@ sconce.trainers.single\_class\_image\_classifier\_trainer module
     :members:
     :undoc-members:
     :show-inheritance:
-
-sconce.trainers.classifier\_trainer module (deprecated)
--------------------------------------------------------
-
-.. automodule:: sconce.trainers.classifier_trainer
diff --git a/tox.ini b/tox.ini
index 1669a08..94592ba 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,5 +28,6 @@ commands = bash {posargs}
 [flake8]
 exclude = .git,.tox,.eggs
 max-line-length = 120
-max-complexity = 9
+# max-complexity is on DataframeMonitor.plot() at the moment.
+max-complexity = 10
 ignore = E12,E128


Test patch:
diff --git a/tests/test_basic_autoencoder.py b/tests/test_basic_autoencoder.py
index 3d6ae7f..11cc6ba 100644
--- a/tests/test_basic_autoencoder.py
+++ b/tests/test_basic_autoencoder.py
@@ -41,5 +41,5 @@ def test_full_run(self):
         trainer.monitor.dataframe_monitor.plot()
 
         test_monitor = trainer.test()
-        test_loss = test_monitor.dataframe_monitor.df['test_loss'].mean()
-        self.assertLess(test_loss, 0.21)
+        val_loss = test_monitor.dataframe_monitor.df['validation_loss'].mean()
+        self.assertLess(val_loss, 0.21)
diff --git a/tests/test_basic_classifier.py b/tests/test_basic_classifier.py
index a7ac71c..ef4fa3a 100644
--- a/tests/test_basic_classifier.py
+++ b/tests/test_basic_classifier.py
@@ -1,5 +1,6 @@
 # flake8: noqa
 from sconce.data_generators import ImageDataGenerator
+from sconce.data_feeds import SingleClassImageFeed
 from sconce.schedules import Cosine
 from sconce.trainers import ClassifierTrainer
 from sconce.models import BasicClassifier
@@ -21,18 +22,18 @@ def test_full_run_from_yaml(self):
         model = BasicClassifier.new_from_yaml_filename(filename)
 
         training_generator = ImageDataGenerator.from_torchvision()
-        test_generator = ImageDataGenerator.from_torchvision(train=False)
+        validation_feed = SingleClassImageFeed.from_torchvision(train=False)
 
         if torch.cuda.is_available():
             model.cuda()
             training_generator.cuda()
-            test_generator.cuda()
+            validation_feed.cuda()
 
         model.set_optimizer(optim.SGD, lr=1e-4, momentum=0.9, weight_decay=1e-4)
 
         trainer = ClassifierTrainer(model=model,
             training_data_generator=training_generator,
-            test_data_generator=test_generator)
+            validation_feed=validation_feed)
 
         self.assertLess(trainer.get_classification_accuracy(), 0.2)
 
diff --git a/tests/test_data_feeds.py b/tests/test_data_feeds.py
new file mode 100644
index 0000000..0ca42aa
--- /dev/null
+++ b/tests/test_data_feeds.py
@@ -0,0 +1,77 @@
+from sconce.data_feeds import SingleClassImageFeed
+
+import torch
+import unittest
+
+
+class TestDataFeeds(unittest.TestCase):
+    num_test_samples = 3
+
+    def test_iterates_forever(self):
+        dg = SingleClassImageFeed.from_torchvision(
+                batch_size=1,
+                fraction=5 / 10_000,
+                train=False)
+        count = 0
+        for batch in dg:
+            count += 1
+            if count >= 5 * 2.2:
+                break
+        self.assertTrue(True, "Can iterate forever")
+
+    def test_properties(self):
+        dg = SingleClassImageFeed.from_torchvision(
+                batch_size=3,
+                fraction=15 / 10_000,
+                train=False)
+        self.assertEqual(len(dg), 5)
+        self.assertEqual(dg.num_samples, 15)
+
+    @unittest.skipIf(not torch.cuda.is_available(),
+            "Cuda unavailable, skipping test")
+    def test_cuda(self):
+        dg = SingleClassImageFeed.from_torchvision(
+                batch_size=1,
+                fraction=5 / 10_000,
+                train=False)
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertFalse(targets.is_cuda)
+
+        dg.cuda()
+        inputs, targets = dg.next()
+        self.assertTrue(inputs.is_cuda)
+        self.assertTrue(targets.is_cuda)
+
+        dg.cuda(False)
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertFalse(targets.is_cuda)
+
+        dg.cuda({'targets': None})
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertTrue(targets.is_cuda)
+
+        dg.cuda(False)
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertFalse(targets.is_cuda)
+
+        dg.cuda({'targets': 0})
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertTrue(targets.is_cuda)
+
+        dg.cuda(False)
+        inputs, targets = dg.next()
+        self.assertFalse(inputs.is_cuda)
+        self.assertFalse(targets.is_cuda)
+
+        dg.cuda({'targets': False, 'inputs': 0})
+        inputs, targets = dg.next()
+        self.assertTrue(inputs.is_cuda)
+        self.assertFalse(targets.is_cuda)
+
+        with self.assertRaises(RuntimeError):
+            dg.cuda({'foo': None})
diff --git a/tests/test_trainer.py b/tests/test_trainer.py
index 443e6cc..006a294 100644
--- a/tests/test_trainer.py
+++ b/tests/test_trainer.py
@@ -38,5 +38,5 @@ def test_full_run(self):
         trainer.monitor.dataframe_monitor.plot()
 
         test_monitor = trainer.test()
-        test_loss = test_monitor.dataframe_monitor.df['test_loss'].mean()
-        self.assertLess(test_loss, 0.21)
+        val_loss = test_monitor.dataframe_monitor.df['validation_loss'].mean()
+        self.assertLess(val_loss, 0.21)