Repository: biolab/orange3

Issue:
t-SNE: Change PCA to Truncated SVD for sparse data
<!--
This is an issue template. Please fill in the relevant details in the
sections below.

Wrap code and verbatim terminal window output into triple backticks, see:
https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code

If you're raising an issue about an add-on (e.g. installed via
Options > Add-ons), raise an issue on the relevant add-on's issue
tracker instead. See: https://github.com/biolab?q=orange3
-->

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.19.0

##### Expected behavior
t-SNE automatically switches to Truncated SVD when receiving sparse data on the input.


##### Actual behavior
t-SNE fails on sparse.


##### Steps to reproduce the behavior
Corpus - Bag of Words - t-SNE.


##### Additional info (worksheets, data, screenshots, ...)
If we don't wish to support Truncated SVD in t-SNE, there should at least be a proper error, not the widget crashing. Even though I think automatic switch to T. SVD is totally ok and doable.




Discussion:

Resolving patch:
diff --git a/Orange/projection/manifold.py b/Orange/projection/manifold.py
--- a/Orange/projection/manifold.py
+++ b/Orange/projection/manifold.py
@@ -1,13 +1,17 @@
 import logging
 import warnings
+from collections import Iterable
 
 import numpy as np
-import fastTSNE
 import scipy.sparse as sp
 from scipy.linalg import eigh as lapack_eigh
 from scipy.sparse.linalg import eigsh as arpack_eigh
 import sklearn.manifold as skl_manifold
 
+import fastTSNE
+import fastTSNE.affinity
+import fastTSNE.initialization
+
 import Orange
 from Orange.data import Table, Domain, ContinuousVariable
 from Orange.distance import Distance, DistanceModel, Euclidean
@@ -187,7 +191,7 @@ def __init__(self, n_components=2, affinity='nearest_neighbors', gamma=None,
 
 
 class TSNEModel(Projection):
-    """A tSNE embedding object. Supports further optimization as well as
+    """A t-SNE embedding object. Supports further optimization as well as
     adding new data into the existing embedding.
 
     Attributes
@@ -208,6 +212,7 @@ def proj_variable(i):
             return self.embedding.domain[i].copy(
                 compute_value=ComputeValueProjector(self, i, transformer))
 
+        super().__init__(self)
         self.embedding_ = embedding
         self.embedding = table
         self.pre_domain = pre_domain
@@ -219,10 +224,26 @@ def proj_variable(i):
     def transform(self, X: np.ndarray, **kwargs) -> fastTSNE.PartialTSNEEmbedding:
         if sp.issparse(X):
             raise TypeError(
-                'A sparse matrix was passed, but dense data is required. Use '
-                'X.toarray() to convert to a dense numpy array.'
+                "A sparse matrix was passed, but dense data is required. Use "
+                "X.toarray() to convert to a dense numpy array."
             )
-        return self.embedding_.transform(X, **kwargs)
+        if isinstance(self.embedding_.affinities, fastTSNE.affinity.Multiscale):
+            perplexity = kwargs.pop("perplexity", False)
+            if perplexity:
+                if not isinstance(self.perplexity, Iterable):
+                    raise ValueError(
+                        "Perplexity should be an instance of `Iterable`, `%s` "
+                        "given." % type(self.perplexity).__name__)
+                perplexity_params = {"perplexities": perplexity}
+            else:
+                perplexity_params = {}
+        else:
+            perplexity_params = {"perplexity": kwargs.pop("perplexity", None)}
+
+        embedding = self.embedding_.prepare_partial(X, **perplexity_params,
+                                                    **kwargs)
+        embedding.optimize(100, inplace=True, momentum=0.4)
+        return embedding
 
     def __call__(self, data: Table, **kwargs) -> Table:
         # If we want to transform new data, ensure that we use correct domain
@@ -234,8 +255,8 @@ def __call__(self, data: Table, **kwargs) -> Table:
 
     def optimize(self, n_iter, inplace=False, propagate_exception=False, **kwargs):
         """Resume optimization for the current embedding."""
-        kwargs = {'n_iter': n_iter, 'inplace': inplace,
-                  'propagate_exception': propagate_exception, **kwargs}
+        kwargs = {"n_iter": n_iter, "inplace": inplace,
+                  "propagate_exception": propagate_exception, **kwargs}
         if inplace:
             self.embedding_.optimize(**kwargs)
             return self
@@ -256,8 +277,11 @@ class TSNE(Projector):
         The number of embedding that the embedding should contain. Note that
         only up to two dimensions are supported as otherwise the process can
         become prohibitively expensive.
-    perplexity : float
-        The desired perplexity of the probability distribution.
+    perplexity : Union[float, List[float]]
+        The desired perplexity of the probability distribution. If using
+        `multiscale` option, this must be a list of perplexities. The most
+        typical multiscale case consists a small perplexity ~50 and a higher
+        perplexity on the order ~N/50.
     learning_rate : float
         The learning rate for t-SNE. Typical values range from 1 to 1000.
         Setting the learning rate too high will result in the crowding problem
@@ -316,6 +340,12 @@ class TSNE(Projector):
         BH tends to be faster for smaller data sets but scales as O(n log n)
         while FItSNE is faster for larger data sets and scales linearly in the
         number of points.
+    multiscale : bool
+        If this option is set to true, the multiscale version of t-SNE will be
+        run. Please note that this can take a substantially longer time than
+        the default t-SNE. Also note that if this option is set, `perplexity`
+        must be a list of perplexities to use, as opposed to the single
+        perplexity value otherwise.
     callbacks : Callable[[int, float, np.ndarray] -> bool]
         The callback should accept three parameters, the first is the current
         iteration, the second is the current KL divergence error and the last
@@ -335,7 +365,7 @@ class TSNE(Projector):
     preprocessors
 
     """
-    name = 't-SNE'
+    name = "t-SNE"
     preprocessors = [
         Orange.preprocess.Continuize(),
         Orange.preprocess.SklImpute(),
@@ -343,31 +373,94 @@ class TSNE(Projector):
 
     def __init__(self, n_components=2, perplexity=30, learning_rate=200,
                  early_exaggeration_iter=250, early_exaggeration=12,
-                 n_iter=750, exaggeration=None, theta=0.5, min_num_intervals=10,
-                 ints_in_interval=1, initialization='random', metric='euclidean',
-                 n_jobs=1, neighbors='exact', negative_gradient_method='bh',
-                 callbacks=None, callbacks_every_iters=50,
+                 n_iter=750, exaggeration=None, theta=0.5,
+                 min_num_intervals=10, ints_in_interval=1,
+                 initialization="pca", metric="euclidean", n_jobs=1,
+                 neighbors="exact", negative_gradient_method="bh",
+                 multiscale=False, callbacks=None, callbacks_every_iters=50,
                  random_state=None, preprocessors=None):
         super().__init__(preprocessors=preprocessors)
-        self.tsne = fastTSNE.TSNE(
-            n_components=n_components, perplexity=perplexity,
-            learning_rate=learning_rate, early_exaggeration=early_exaggeration,
-            early_exaggeration_iter=early_exaggeration_iter, n_iter=n_iter,
-            exaggeration=exaggeration, theta=theta, min_num_intervals=min_num_intervals,
-            ints_in_interval=ints_in_interval, initialization=initialization,
-            metric=metric, n_jobs=n_jobs, neighbors=neighbors,
-            negative_gradient_method=negative_gradient_method,
-            callbacks=callbacks, callbacks_every_iters=callbacks_every_iters,
-            random_state=random_state
-        )
+        self.n_components = n_components
+        self.perplexity = perplexity
+        self.learning_rate = learning_rate
+        self.early_exaggeration = early_exaggeration
+        self.early_exaggeration_iter = early_exaggeration_iter
+        self.n_iter = n_iter
+        self.exaggeration = exaggeration
+        self.theta = theta
+        self.min_num_intervals = min_num_intervals
+        self.ints_in_interval = ints_in_interval
+        self.initialization = initialization
+        self.metric = metric
+        self.n_jobs = n_jobs
+        self.neighbors = neighbors
+        self.negative_gradient_method = negative_gradient_method
+        self.multiscale = multiscale
+        self.callbacks = callbacks
+        self.callbacks_every_iters = callbacks_every_iters
+        self.random_state = random_state
 
     def fit(self, X: np.ndarray, Y: np.ndarray = None) -> fastTSNE.TSNEEmbedding:
+        # Sparse data are not supported
         if sp.issparse(X):
             raise TypeError(
-                'A sparse matrix was passed, but dense data is required. Use '
-                'X.toarray() to convert to a dense numpy array.'
+                "A sparse matrix was passed, but dense data is required. Use "
+                "X.toarray() to convert to a dense numpy array."
             )
-        return self.tsne.fit(X)
+
+        # Build up the affinity matrix, using multiscale if needed
+        if self.multiscale:
+            # The local perplexity should be on the order ~50 while the higher
+            # perplexity should be on the order ~N/50
+            if not isinstance(self.perplexity, Iterable):
+                raise ValueError(
+                    "Perplexity should be an instance of `Iterable`, `%s` "
+                    "given." % type(self.perplexity).__name__)
+            affinities = fastTSNE.affinity.Multiscale(
+                X, perplexities=self.perplexity, metric=self.metric,
+                method=self.neighbors, random_state=self.random_state, n_jobs=self.n_jobs)
+        else:
+            if isinstance(self.perplexity, Iterable):
+                raise ValueError(
+                    "Perplexity should be an instance of `float`, `%s` "
+                    "given." % type(self.perplexity).__name__)
+            affinities = fastTSNE.affinity.PerplexityBasedNN(
+                X, perplexity=self.perplexity, metric=self.metric,
+                method=self.neighbors, random_state=self.random_state, n_jobs=self.n_jobs)
+
+        # Create an initial embedding
+        if isinstance(self.initialization, np.ndarray):
+            initialization = self.initialization
+        elif self.initialization == "pca":
+            initialization = fastTSNE.initialization.pca(
+                X, self.n_components, random_state=self.random_state)
+        elif self.initialization == "random":
+            initialization = fastTSNE.initialization.random(
+                X.shape[0], self.n_components, random_state=self.random_state)
+        else:
+            raise ValueError(
+                "Invalid initialization `%s`. Please use either `pca` or "
+                "`random` or provide a numpy array." % self.initialization)
+
+        embedding = fastTSNE.TSNEEmbedding(
+            initialization, affinities, learning_rate=self.learning_rate,
+            theta=self.theta, min_num_intervals=self.min_num_intervals,
+            ints_in_interval=self.ints_in_interval, n_jobs=self.n_jobs,
+            negative_gradient_method=self.negative_gradient_method,
+            callbacks=self.callbacks, callbacks_every_iters=self.callbacks_every_iters,
+        )
+
+        # Run standard t-SNE optimization
+        embedding.optimize(
+            n_iter=self.early_exaggeration_iter, exaggeration=self.early_exaggeration,
+            inplace=True, momentum=0.5, propagate_exception=True,
+        )
+        embedding.optimize(
+            n_iter=self.n_iter, exaggeration=self.exaggeration,
+            inplace=True, momentum=0.8, propagate_exception=True,
+        )
+
+        return embedding
 
     def __call__(self, data: Table) -> TSNEModel:
         # Preprocess the data - convert discrete to continuous
@@ -378,9 +471,9 @@ def __call__(self, data: Table) -> TSNEModel:
 
         # The results should be accessible in an Orange table, which doesn't
         # need the full embedding attributes and is cast into a regular array
-        n = self.tsne.n_components
-        postfixes = ['x', 'y'] if n == 2 else list(range(1, n + 1))
-        tsne_cols = [ContinuousVariable(f't-SNE-{p}') for p in postfixes]
+        n = self.n_components
+        postfixes = ["x", "y"] if n == 2 else list(range(1, n + 1))
+        tsne_cols = [ContinuousVariable(f"t-SNE-{p}") for p in postfixes]
         embedding_domain = Domain(tsne_cols, data.domain.class_vars, data.domain.metas)
         embedding_table = Table(embedding_domain, embedding.view(np.ndarray), data.Y, data.metas)
 
diff --git a/Orange/widgets/unsupervised/owtsne.py b/Orange/widgets/unsupervised/owtsne.py
--- a/Orange/widgets/unsupervised/owtsne.py
+++ b/Orange/widgets/unsupervised/owtsne.py
@@ -3,9 +3,12 @@
 from AnyQt.QtCore import Qt, QTimer
 from AnyQt.QtWidgets import QFormLayout
 
+import fastTSNE.initialization
+
 from Orange.data import Table, Domain
 from Orange.preprocess.preprocess import Preprocess, ApplyDomain
-from Orange.projection import PCA, TSNE
+from Orange.projection import PCA, TSNE, TruncatedSVD
+from Orange.projection.manifold import TSNEModel
 from Orange.widgets import gui
 from Orange.widgets.settings import Setting, SettingProvider
 from Orange.widgets.utils.widgetpreview import WidgetPreview
@@ -14,15 +17,43 @@
 from Orange.widgets.widget import Msg, Output
 
 
-def compute_tsne(data, perplexity, iter, init):
-    negative_gradient_method = 'fft' if len(data.X) > 10000 else 'bh'
-    neighbor_method = 'approx' if len(data.X) > 10000 else 'exact'
-    tsne = TSNE(
-        perplexity=perplexity, n_iter=iter, initialization=init, theta=.8,
-        early_exaggeration_iter=0, negative_gradient_method=negative_gradient_method,
-        neighbors=neighbor_method, random_state=0
-    )
-    return tsne(data)
+class TSNERunner:
+    def __init__(self, tsne: TSNEModel, step_size=50):
+        self.embedding = tsne
+        self.iterations_done = 0
+        self.step_size = step_size
+
+        # Larger data sets need a larger number of iterations
+        if self.n_samples > 100_000:
+            self.early_exagg_iter, self.n_iter = 500, 1000
+        else:
+            self.early_exagg_iter, self.n_iter = 250, 750
+
+    @property
+    def n_samples(self):
+        return self.embedding.embedding_.shape[0]
+
+    def run_optimization(self):
+        total_iterations = self.early_exagg_iter + self.n_iter
+
+        # Default values of early exaggeration phase
+        exaggeration, momentum = 12, 0.5
+
+        current_iter = self.iterations_done
+        while not current_iter >= total_iterations:
+            # Switch to normal regime if early exaggeration phase is over
+            if current_iter >= self.early_exagg_iter:
+                exaggeration, momentum = 1, 0.8
+
+            # Resume optimization for some number of steps
+            self.embedding.optimize(
+                self.step_size, inplace=True, exaggeration=exaggeration,
+                momentum=momentum,
+            )
+
+            current_iter += self.step_size
+
+            yield self.embedding, current_iter / total_iterations
 
 
 class OWtSNEGraph(OWScatterPlotBase):
@@ -42,6 +73,8 @@ class OWtSNE(OWDataProjectionWidget):
     settings_version = 3
     max_iter = Setting(300)
     perplexity = Setting(30)
+    multiscale = Setting(True)
+    exaggeration = Setting(1)
     pca_components = Setting(20)
 
     GRAPH_CLASS = OWtSNEGraph
@@ -66,6 +99,7 @@ def __init__(self):
         super().__init__()
         self.pca_data = None
         self.projection = None
+        self.tsne_runner = None
         self.__update_loop = None
         # timer for scheduling updates
         self.__timer = QTimer(self, singleShot=True, interval=1,
@@ -77,13 +111,6 @@ def __init__(self):
     def _add_controls(self):
         self._add_controls_start_box()
         super()._add_controls()
-        # Because sc data frequently has many genes,
-        # showing all attributes in combo boxes can cause problems
-        # QUICKFIX: Remove a separator and attributes from order
-        # (leaving just the class and metas)
-        self.models = self.gui.points_models
-        for model in self.models:
-            model.order = model.order[:-2]
 
     def _add_controls_start_box(self):
         box = gui.vBox(self.controlArea, True)
@@ -91,25 +118,35 @@ def _add_controls_start_box(self):
             labelAlignment=Qt.AlignLeft,
             formAlignment=Qt.AlignLeft,
             fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow,
-            verticalSpacing=10
+            verticalSpacing=10,
         )
 
-        form.addRow(
-            "Max iterations:",
-            gui.spin(box, self, "max_iter", 1, 2000, step=50))
-
-        form.addRow(
-            "Perplexity:",
-            gui.spin(box, self, "perplexity", 1, 100, step=1))
+        self.perplexity_spin = gui.spin(
+            box, self, "perplexity", 1, 500, step=1, alignment=Qt.AlignRight)
+        form.addRow("Perplexity:", self.perplexity_spin)
+        form.addRow(gui.checkBox(
+            box, self, "multiscale", label="Preserve global structure",
+            callback=self._multiscale_changed
+        ))
+        self._multiscale_changed()
+
+        sbe = gui.hBox(self.controlArea, False, addToLayout=False)
+        gui.hSlider(
+            sbe, self, "exaggeration", minValue=1, maxValue=4, step=1)
+        form.addRow("Exaggeration:", sbe)
+
+        sbp = gui.hBox(self.controlArea, False, addToLayout=False)
+        gui.hSlider(
+            sbp, self, "pca_components", minValue=2, maxValue=50, step=1)
+        form.addRow("PCA components:", sbp)
 
         box.layout().addLayout(form)
 
         gui.separator(box, 10)
         self.runbutton = gui.button(box, self, "Run", callback=self._toggle_run)
 
-        gui.separator(box, 10)
-        gui.hSlider(box, self, "pca_components", label="PCA components:",
-                    minValue=2, maxValue=50, step=1)
+    def _multiscale_changed(self):
+        self.perplexity_spin.setEnabled(not self.multiscale)
 
     def check_data(self):
         def error(err):
@@ -123,7 +160,7 @@ def error(err):
             elif not self.data.domain.attributes:
                 error(self.Error.no_attributes)
             elif not self.data.is_sparse() and \
-                    np.allclose(self.data.X - self.data.X[0], 0):
+                    not np.sum(np.nanstd(self.data.X, axis=0)):
                 error(self.Error.constant_data)
             elif not self.data.is_sparse() and \
                     np.all(~np.isfinite(self.data.X)):
@@ -161,34 +198,43 @@ def pca_preprocessing(self):
         if self.pca_data is not None and \
                 self.pca_data.X.shape[1] == self.pca_components:
             return
-        pca = PCA(n_components=self.pca_components, random_state=0)
-        model = pca(self.data)
+        cls = TruncatedSVD if self.data.is_sparse() else PCA
+        projector = cls(n_components=self.pca_components, random_state=0)
+        model = projector(self.data)
         self.pca_data = model(self.data)
 
     def __start(self):
         self.pca_preprocessing()
-        initial = 'random' if self.projection is None \
-            else self.projection.embedding.X
-        step_size = 50
-
-        def update_loop(data, max_iter, step, embedding):
-            # NOTE: this code MUST NOT call into QApplication.processEvents
-            done = False
-            iterations_done = 0
-
-            while not done:
-                step_iter = min(max_iter - iterations_done, step)
-                projection = compute_tsne(
-                    data, self.perplexity, step_iter, embedding)
-                embedding = projection.embedding.X
-                iterations_done += step_iter
-                if iterations_done >= max_iter:
-                    done = True
-
-                yield projection, iterations_done / max_iter
-
-        self.__set_update_loop(update_loop(
-            self.pca_data, self.max_iter, step_size, initial))
+
+        # We call PCA through fastTSNE because it involves scaling. Instead of
+        # worrying about this ourselves, we'll let the library worry for us.
+        initialization = fastTSNE.initialization.pca(
+            self.pca_data.X, n_components=2, random_state=0)
+
+        # Compute perplexity settings for multiscale
+        n_samples = self.pca_data.X.shape[0]
+        if self.multiscale:
+            perplexity = min((n_samples - 1) / 3, 50), min((n_samples - 1) / 3, 500)
+        else:
+            perplexity = self.perplexity
+
+        # Determine whether to use settings for large data sets
+        if n_samples > 10_000:
+            neighbor_method, gradient_method = "approx", "fft"
+        else:
+            neighbor_method, gradient_method = "exact", "bh"
+
+        # Set number of iterations to 0 - these will be run subsequently
+        self.projection = TSNE(
+            n_components=2, perplexity=perplexity, multiscale=self.multiscale,
+            early_exaggeration_iter=0, n_iter=0, initialization=initialization,
+            exaggeration=self.exaggeration, neighbors=neighbor_method,
+            negative_gradient_method=gradient_method, random_state=0
+        )(self.pca_data)
+
+        self.tsne_runner = TSNERunner(self.projection, step_size=50)
+
+        self.__set_update_loop(self.tsne_runner.run_optimization())
         self.progressBarInit(processEvents=None)
 
     def __set_update_loop(self, loop):


Test patch:
diff --git a/Orange/tests/test_manifold.py b/Orange/tests/test_manifold.py
--- a/Orange/tests/test_manifold.py
+++ b/Orange/tests/test_manifold.py
@@ -166,6 +166,12 @@ def test_transform(self):
         # The new embedding should not contain NaNs
         self.assertFalse(np.any(np.isnan(new_embedding.X)))
 
+    def test_multiscale(self):
+        tsne = TSNE(perplexity=(10, 10), multiscale=True)
+        model = tsne(self.iris[::2])
+        embedding = model(self.iris[1::2])
+        self.assertFalse(np.any(np.isnan(embedding.X)))
+
     def test_continue_optimization(self):
         tsne = TSNE(n_iter=100)
         model = tsne(self.iris)
diff --git a/Orange/widgets/tests/base.py b/Orange/widgets/tests/base.py
--- a/Orange/widgets/tests/base.py
+++ b/Orange/widgets/tests/base.py
@@ -952,8 +952,7 @@ def test_dragging_tooltip(self):
 
     def test_sparse_data(self, timeout=DEFAULT_TIMEOUT):
         """Test widget for sparse data"""
-        table = Table("iris")
-        table.X = sp.csr_matrix(table.X)
+        table = Table("iris").to_sparse()
         self.assertTrue(sp.issparse(table.X))
         self.send_signal(self.widget.Inputs.data, table)
         if self.widget.isBlocking():
diff --git a/Orange/widgets/unsupervised/tests/test_owtsne.py b/Orange/widgets/unsupervised/tests/test_owtsne.py
--- a/Orange/widgets/unsupervised/tests/test_owtsne.py
+++ b/Orange/widgets/unsupervised/tests/test_owtsne.py
@@ -3,9 +3,11 @@
 
 from Orange.data import DiscreteVariable, ContinuousVariable, Domain, Table
 from Orange.preprocess import Preprocess
+from Orange.projection.manifold import TSNE
 from Orange.widgets.tests.base import (
     WidgetTest, WidgetOutputsTestMixin, ProjectionWidgetTestMixin
 )
+from Orange.widgets.unsupervised import owtsne
 from Orange.widgets.unsupervised.owtsne import OWtSNE
 
 
@@ -21,6 +23,22 @@ def setUpClass(cls):
         cls.signal_data = cls.data
 
     def setUp(self):
+        def fit(*args, **_):
+            return np.ones((len(args[1]), 2), float)
+
+        def transform(*args, **_):
+            return np.ones((len(args[1]), 2), float)
+
+        def optimize(*_, **__):
+            return TSNE()()
+
+        self._fit = owtsne.TSNE.fit
+        self._transform = owtsne.TSNEModel.transform
+        self._optimize = owtsne.TSNEModel.optimize
+        owtsne.TSNE.fit = fit
+        owtsne.TSNEModel.transform = transform
+        owtsne.TSNEModel.optimize = optimize
+
         self.widget = self.create_widget(OWtSNE)
 
         self.class_var = DiscreteVariable('Stage name', values=['STG1', 'STG2'])
@@ -28,6 +46,14 @@ def setUp(self):
         self.domain = Domain(self.attributes, class_vars=self.class_var)
         self.empty_domain = Domain([], class_vars=self.class_var)
 
+    def tearDown(self):
+        self.reset_tsne()
+
+    def reset_tsne(self):
+        owtsne.TSNE.fit = self._fit
+        owtsne.TSNEModel.transform = self._transform
+        owtsne.TSNEModel.optimize = self._optimize
+
     def test_wrong_input(self):
         # no data
         self.data = None
@@ -96,6 +122,17 @@ def test_output_preprocessor(self):
         self.assertEqual([a.name for a in transformed.domain.attributes],
                          [m.name for m in output.domain.metas[:2]])
 
+    def test_multiscale_changed(self):
+        self.assertTrue(self.widget.controls.multiscale.isChecked())
+        self.assertFalse(self.widget.perplexity_spin.isEnabled())
+        self.widget.controls.multiscale.setChecked(False)
+        self.assertTrue(self.widget.perplexity_spin.isEnabled())
+
+        settings = self.widget.settingsHandler.pack_data(self.widget)
+        w = self.create_widget(OWtSNE, stored_settings=settings)
+        self.assertFalse(w.controls.multiscale.isChecked())
+        self.assertTrue(w.perplexity_spin.isEnabled())
+
 
 if __name__ == '__main__':
     unittest.main()