Repository: biolab/orange3

Issue:
Distance widget should compute in a new thread
We just use Distance widget with Vesna. We forgot that it is set to measure distances with rows by default. Unfortunately, the data set was not small enough.

Distances should be implemented in a separate thread.


Discussion:

Resolving patch:
diff --git a/Orange/distance/base.py b/Orange/distance/base.py
--- a/Orange/distance/base.py
+++ b/Orange/distance/base.py
@@ -116,6 +116,8 @@ class Distance:
         impute (bool):
             if `True` (default is `False`), nans in the computed distances
             are replaced with zeros, and infs with very large numbers.
+        callback (callable or None):
+            callback function
 
     Attributes:
         axis (int):
@@ -162,10 +164,12 @@ class Distance:
     axis = 1
     impute = False
 
-    def __new__(cls, e1=None, e2=None, axis=1, impute=False, **kwargs):
+    def __new__(cls, e1=None, e2=None, axis=1, impute=False,
+                callback=None, **kwargs):
         self = super().__new__(cls)
         self.axis = axis
         self.impute = impute
+        self.callback = callback
         # Ugly, but needed to allow allow setting subclass-specific parameters
         # (such as normalize) when `e1` is not `None` and the `__new__` in the
         # subclass is skipped
@@ -225,11 +229,14 @@ class DistanceModel:
         impute (bool):
             if `True` (default is `False`), nans in the computed distances
             are replaced with zeros, and infs with very large numbers
+        callback (callable or None):
+            callback function
 
     """
-    def __init__(self, axis, impute=False):
+    def __init__(self, axis, impute=False, callback=None):
         self._axis = axis
         self.impute = impute
+        self.callback = callback
 
     @property
     def axis(self):
@@ -291,9 +298,10 @@ class FittedDistanceModel(DistanceModel):
         continuous (np.ndarray): bool array indicating continuous attributes
         normalize (bool):
             if `True` (default is `False`) continuous columns are normalized
+        callback (callable or None): callback function
     """
-    def __init__(self, attributes, axis=1, impute=False):
-        super().__init__(axis, impute)
+    def __init__(self, attributes, axis=1, impute=False, callback=None):
+        super().__init__(axis, impute, callback)
         self.attributes = attributes
         self.discrete = None
         self.continuous = None
@@ -464,7 +472,8 @@ def fit_rows(self, attributes, x, n_vals):
             continuous, discrete,
             offsets[:curr_cont], scales[:curr_cont],
             dist_missing2_cont[:curr_cont],
-            dist_missing_disc, dist_missing2_disc)
+            dist_missing_disc, dist_missing2_disc,
+            self.callback)
 
     @staticmethod
     def get_discrete_stats(column, n_bins):
diff --git a/Orange/distance/distance.py b/Orange/distance/distance.py
--- a/Orange/distance/distance.py
+++ b/Orange/distance/distance.py
@@ -1,3 +1,4 @@
+from typing import Callable
 import warnings
 from unittest.mock import patch
 
@@ -16,6 +17,65 @@
                    SklDistance, _orange_to_numpy)
 
 
+def _safe_sparse_dot(a: np.ndarray, b: np.ndarray,
+                     dense_output: bool = False, step: int = 100,
+                     callback: Callable = None) -> np.ndarray:
+    if sp.issparse(a) or sp.issparse(b):
+        return safe_sparse_dot(a, b, dense_output)
+    else:
+        return _interruptible_dot(a, b, step, callback)
+
+
+def _interruptible_dot(a: np.ndarray, b: np.ndarray, step: int = 100,
+                       callback: Callable = None) -> np.ndarray:
+    if callback is None:
+        callback = lambda x: x
+
+    if not isinstance(a, np.ndarray):
+        a = np.array(a)
+    if not isinstance(b, np.ndarray):
+        b = np.array(b)
+    if a.ndim < 2 or b.ndim < 2:
+        return np.dot(a, b)
+
+    c = np.zeros((a.shape[0], b.shape[1]), dtype=float)
+    n_cols = b.shape[1]
+    for col in range(0, n_cols, step):
+        callback(col * 100 / n_cols)
+        c[:, col: col + step] = np.dot(a, b[:, col: col + step])
+    return c
+
+
+def _interruptible_sqrt(a: np.ndarray, step: int = 100,
+                        callback: Callable = None) -> np.ndarray:
+    if callback is None:
+        callback = lambda x: x
+
+    if not isinstance(a, np.ndarray):
+        a = np.array(a)
+    if a.ndim < 2:
+        return np.sqrt(a)
+    new_a = np.zeros(a.shape, dtype=float)
+    n_rows = len(a)
+    for row in range(0, n_rows, step):
+        callback(row * 100 / n_rows)
+        new_a[row: row + step, :] = np.sqrt(a[row: row + step, :])
+    return new_a
+
+
+class StepwiseCallbacks:
+    def __init__(self, callback, weights):
+        self.step_counter = -1
+        self.callback = callback
+        self.weights = weights
+
+    def next(self):
+        self.step_counter += 1
+        base = sum(self.weights[:self.step_counter])
+        step = self.weights[self.step_counter] / sum(self.weights)
+        return lambda i: self.callback and self.callback(base + i * step)
+
+
 class EuclideanRowsModel(FittedDistanceModel):
     """
     Model for computation of Euclidean distances between rows.
@@ -26,8 +86,8 @@ class EuclideanRowsModel(FittedDistanceModel):
     def __init__(self, attributes, impute, normalize,
                  continuous, discrete,
                  means, stdvars, dist_missing2_cont,
-                 dist_missing_disc, dist_missing2_disc):
-        super().__init__(attributes, 1, impute)
+                 dist_missing_disc, dist_missing2_disc, callback):
+        super().__init__(attributes, 1, impute, callback)
         self.normalize = normalize
         self.continuous = continuous
         self.discrete = discrete
@@ -48,6 +108,8 @@ def compute_distances(self, x1, x2=None):
         - calls a function in Cython that adds the contributions of discrete
           columns
         """
+        callbacks = StepwiseCallbacks(self.callback, [20, 10, 50, 5, 15])
+
         if self.continuous.any():
             data1, data2 = self.continuous_columns(
                 x1, x2, self.means, np.sqrt(2 * self.vars))
@@ -58,7 +120,8 @@ def compute_distances(self, x1, x2=None):
                 yy = row_norms(data2, squared=True)[np.newaxis, :]
             else:
                 yy = xx.T
-            distances = safe_sparse_dot(data1, data2.T, dense_output=True)
+            distances = _safe_sparse_dot(data1, data2.T, dense_output=True,
+                                         callback=callbacks.next())
             distances *= -2
             distances += xx
             distances += yy
@@ -70,7 +133,7 @@ def compute_distances(self, x1, x2=None):
                 else _distance.fix_euclidean_rows
             fixer(distances, data1, data2,
                   self.means, self.vars, self.dist_missing2_cont,
-                  x2 is not None)
+                  x2 is not None, callbacks.next())
         else:
             distances = np.zeros((x1.shape[0],
                                   (x2 if x2 is not None else x1).shape[0]))
@@ -79,11 +142,11 @@ def compute_distances(self, x1, x2=None):
             data1, data2 = self.discrete_columns(x1, x2)
             _distance.euclidean_rows_discrete(
                 distances, data1, data2, self.dist_missing_disc,
-                self.dist_missing2_disc, x2 is not None)
+                self.dist_missing2_disc, x2 is not None, callbacks.next())
 
         if x2 is None:
-            _distance.lower_to_symmetric(distances)
-        return np.sqrt(distances)
+            _distance.lower_to_symmetric(distances, callbacks.next())
+        return _interruptible_sqrt(distances, callback=callbacks.next())
 
 
 class EuclideanColumnsModel(FittedDistanceModel):
@@ -93,8 +156,9 @@ class EuclideanColumnsModel(FittedDistanceModel):
     Means are used as offsets for normalization, and two deviations are
     used for scaling.
     """
-    def __init__(self, attributes, impute, normalize, means, stdvars):
-        super().__init__(attributes, 0, impute)
+    def __init__(self, attributes, impute, normalize, means, stdvars,
+                 callback=None):
+        super().__init__(attributes, 0, impute, callback)
         self.normalize = normalize
         self.means = means
         self.vars = stdvars
@@ -108,13 +172,15 @@ def compute_distances(self, x1, x2=None):
         - calls a function in Cython that adds the contributions of discrete
           columns
         """
+        callbacks = StepwiseCallbacks(self.callback, [40, 30, 30])
+
         if self.normalize:
             x1 = x1 - self.means
             x1 /= np.sqrt(2 * self.vars)
-
         # adapted from sklearn.metric.euclidean_distances
         xx = row_norms(x1.T, squared=True)[:, np.newaxis]
-        distances = safe_sparse_dot(x1.T, x1, dense_output=True)
+        distances = _safe_sparse_dot(x1.T, x1, dense_output=True,
+                                     callback=callbacks.next())
         distances *= -2
         distances += xx
         distances += xx.T
@@ -124,8 +190,8 @@ def compute_distances(self, x1, x2=None):
 
         fixer = _distance.fix_euclidean_cols_normalized if self.normalize \
             else _distance.fix_euclidean_cols
-        fixer(distances, x1, self.means, self.vars)
-        return np.sqrt(distances)
+        fixer(distances, x1, self.means, self.vars, callbacks.next())
+        return _interruptible_sqrt(distances, callback=callbacks.next())
 
 
 class Euclidean(FittedDistance):
@@ -135,9 +201,11 @@ class Euclidean(FittedDistance):
     fallback = SklDistance('euclidean')
     rows_model_type = EuclideanRowsModel
 
-    def __new__(cls, e1=None, e2=None, axis=1, impute=False, normalize=False):
+    def __new__(cls, e1=None, e2=None, axis=1, impute=False, normalize=False,
+                callback=None):
         # pylint: disable=arguments-differ
-        return super().__new__(cls, e1, e2, axis, impute, normalize=normalize)
+        return super().__new__(cls, e1, e2, axis, impute, callback,
+                               normalize=normalize)
 
     def get_continuous_stats(self, column):
         """
@@ -180,7 +248,8 @@ def nowarn(msg, cat, *args, **kwargs):
         if self.normalize and not stdvars.all():
             raise ValueError("some columns are constant")
         return EuclideanColumnsModel(
-            attributes, self.impute, self.normalize, means, stdvars)
+            attributes, self.impute, self.normalize, means, stdvars,
+            self.callback)
 
 
 class ManhattanRowsModel(FittedDistanceModel):
@@ -193,8 +262,8 @@ class ManhattanRowsModel(FittedDistanceModel):
     def __init__(self, attributes, impute, normalize,
                  continuous, discrete,
                  medians, mads, dist_missing2_cont,
-                 dist_missing_disc, dist_missing2_disc):
-        super().__init__(attributes, 1, impute)
+                 dist_missing_disc, dist_missing2_disc, callback=None):
+        super().__init__(attributes, 1, impute, callback)
         self.normalize = normalize
         self.continuous = continuous
         self.discrete = discrete
@@ -213,19 +282,21 @@ def compute_distances(self, x1, x2):
         - adds the contributions of discrete columns using the same function as
           the Euclidean distance
         """
+        callbacks = StepwiseCallbacks(self.callback, [5, 5, 60, 30])
+
         if self.continuous.any():
             data1, data2 = self.continuous_columns(
                 x1, x2, self.medians, 2 * self.mads)
             distances = _distance.manhattan_rows_cont(
-                data1, data2, x2 is not None)
+                data1, data2, x2 is not None, callbacks.next())
             if self.normalize:
                 _distance.fix_manhattan_rows_normalized(
-                    distances, data1, data2, x2 is not None)
+                    distances, data1, data2, x2 is not None, callbacks.next())
             else:
                 _distance.fix_manhattan_rows(
                     distances, data1, data2,
                     self.medians, self.mads, self.dist_missing2_cont,
-                    x2 is not None)
+                    x2 is not None, callbacks.next())
         else:
             distances = np.zeros((x1.shape[0],
                                   (x2 if x2 is not None else x1).shape[0]))
@@ -235,10 +306,10 @@ def compute_distances(self, x1, x2):
             # For discrete attributes, Euclidean is same as Manhattan
             _distance.euclidean_rows_discrete(
                 distances, data1, data2, self.dist_missing_disc,
-                self.dist_missing2_disc, x2 is not None)
+                self.dist_missing2_disc, x2 is not None, callbacks.next())
 
         if x2 is None:
-            _distance.lower_to_symmetric(distances)
+            _distance.lower_to_symmetric(distances, callbacks.next())
         return distances
 
 
@@ -250,8 +321,9 @@ class ManhattanColumnsModel(FittedDistanceModel):
     used for scaling.
     """
 
-    def __init__(self, attributes, impute, normalize, medians, mads):
-        super().__init__(attributes, 0, impute)
+    def __init__(self, attributes, impute, normalize, medians, mads,
+                 callback=None):
+        super().__init__(attributes, 0, impute, callback)
         self.normalize = normalize
         self.medians = medians
         self.mads = mads
@@ -261,7 +333,9 @@ def compute_distances(self, x1, x2=None):
             x1 = x1 - self.medians
             x1 /= 2
             x1 /= self.mads
-        return _distance.manhattan_cols(x1, self.medians, self.mads, self.normalize)
+        callback = self.callback or (lambda x: x)
+        return _distance.manhattan_cols(x1, self.medians, self.mads,
+                                        self.normalize, callback)
 
 
 class Manhattan(FittedDistance):
@@ -271,9 +345,11 @@ class Manhattan(FittedDistance):
     fallback = SklDistance('manhattan')
     rows_model_type = ManhattanRowsModel
 
-    def __new__(cls, e1=None, e2=None, axis=1, impute=False, normalize=False):
+    def __new__(cls, e1=None, e2=None, axis=1, impute=False, normalize=False,
+                callback=None):
         # pylint: disable=arguments-differ
-        return super().__new__(cls, e1, e2, axis, impute, normalize=normalize)
+        return super().__new__(cls, e1, e2, axis, impute, callback,
+                               normalize=normalize)
 
     def get_continuous_stats(self, column):
         """
@@ -310,7 +386,8 @@ def fit_cols(self, attributes, x, n_vals):
                 "some columns have zero absolute distance from median, "
                 "or no values")
         return ManhattanColumnsModel(
-            attributes, self.impute, self.normalize, medians, mads)
+            attributes, self.impute, self.normalize, medians, mads,
+            self.callback)
 
 
 class Cosine(FittedDistance):
@@ -336,7 +413,7 @@ def fit_rows(self, attributes, x, n_vals):
         means = util.nanmean(x, axis=0)
         means = np.nan_to_num(means)
         return self.CosineModel(attributes, self.axis, self.impute,
-                                discrete, means)
+                                discrete, means, self.callback)
 
     fit_cols = fit_rows
 
@@ -347,8 +424,8 @@ def get_continuous_stats(self, column):
     class CosineModel(FittedDistanceModel):
         """Model for computation of cosine distances across rows and columns.
         All non-zero discrete values are treated as 1."""
-        def __init__(self, attributes, axis, impute, discrete, means):
-            super().__init__(attributes, axis, impute)
+        def __init__(self, attributes, axis, impute, discrete, means, callback):
+            super().__init__(attributes, axis, impute, callback)
             self.discrete = discrete
             self.means = means
 
@@ -375,7 +452,7 @@ def prepare_data(x):
 
             data1 = prepare_data(x1)
             data2 = data1 if x2 is None else prepare_data(x2)
-            dist = safe_sparse_dot(data1, data2.T)
+            dist = _safe_sparse_dot(data1, data2.T, callback=self.callback)
             np.clip(dist, -1, 1, out=dist)
             if x2 is None:
                 diag = np.diag_indices_from(dist)
@@ -388,8 +465,8 @@ class JaccardModel(FittedDistanceModel):
     Model for computation of cosine distances across rows and columns.
     All non-zero values are treated as 1.
     """
-    def __init__(self, attributes, axis, impute, ps):
-        super().__init__(attributes, axis, impute)
+    def __init__(self, attributes, axis, impute, ps, callback):
+        super().__init__(attributes, axis, impute, callback)
         self.ps = ps
 
     def compute_distances(self, x1, x2):
@@ -412,25 +489,30 @@ def _compute_dense(self, x1, x2):
         # view is false positive, pylint: disable=no-member
         nonzeros1 = np.not_equal(x1, 0).view(np.int8)
         if self.axis == 1:
-            nans1 = _distance.any_nan_row(x1)
+            weights = [5, 45, 50] if x2 is None else [5, 5, 45, 45]
+            callbacks = StepwiseCallbacks(self.callback, weights)
+
+            nans1 = _distance.any_nan_row(x1, callbacks.next())
             if x2 is None:
                 nonzeros2, nans2 = nonzeros1, nans1
             else:
                 nonzeros2 = np.not_equal(x2, 0).view(np.int8)
-                nans2 = _distance.any_nan_row(x2)
+                nans2 = _distance.any_nan_row(x2, callbacks.next())
             return _distance.jaccard_rows(
                 nonzeros1, nonzeros2,
                 x1, x1 if x2 is None else x2,
                 nans1, nans2,
-                self.ps,
-                x2 is not None)
+                self.ps, x2 is not None,
+                callbacks.next(), callbacks.next()
+            )
         else:
-            nans1 = _distance.any_nan_row(x1.T)
+            callbacks = StepwiseCallbacks(self.callback, [10, 90])
+            nans1 = _distance.any_nan_row(x1.T, callbacks.next())
             return _distance.jaccard_cols(
-                nonzeros1, x1, nans1, self.ps)
+                nonzeros1, x1, nans1, self.ps, callbacks.next())
 
-    @staticmethod
-    def _compute_sparse(x1, x2=None):
+    def _compute_sparse(self, x1, x2=None):
+        callback = self.callback or (lambda x: x)
         symmetric = x2 is None
         if symmetric:
             x2 = x1
@@ -441,6 +523,7 @@ def _compute_sparse(x1, x2=None):
         n, m = x1.shape[0], x2.shape[0]
         matrix = np.zeros((n, m))
         for i in range(n):
+            callback(i * 100 / n)
             xi_ind = set(x1[i].indices)
             for j in range(i if symmetric else m):
                 union = len(xi_ind.union(x2[j].indices))
@@ -470,7 +553,8 @@ def fit_rows(self, attributes, x, n_vals):
             ps = np.fromiter(
                 (_distance.p_nonzero(x[:, col]) for col in range(len(n_vals))),
                 dtype=np.double, count=len(n_vals))
-        return JaccardModel(attributes, self.axis, self.impute, ps)
+        return JaccardModel(attributes, self.axis, self.impute,
+                            ps, self.callback)
 
     fit_cols = fit_rows
 
diff --git a/Orange/widgets/unsupervised/owdistances.py b/Orange/widgets/unsupervised/owdistances.py
--- a/Orange/widgets/unsupervised/owdistances.py
+++ b/Orange/widgets/unsupervised/owdistances.py
@@ -5,7 +5,9 @@
 import Orange.data
 import Orange.misc
 from Orange import distance
-from Orange.widgets import gui, settings
+from Orange.widgets import gui
+from Orange.widgets.settings import Setting
+from Orange.widgets.utils.concurrent import TaskState, ConcurrentWidgetMixin
 from Orange.widgets.utils.sql import check_sql_input
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.widget import OWWidget, Msg, Input, Output
@@ -26,7 +28,30 @@
 ]
 
 
-class OWDistances(OWWidget):
+class InterruptException(Exception):
+    pass
+
+
+class DistanceRunner:
+    @staticmethod
+    def run(data: Orange.data.Table, metric: distance, normalized_dist: bool,
+            axis: int, state: TaskState) -> Orange.misc.DistMatrix:
+        if data is None:
+            return None
+
+        def callback(i: float) -> bool:
+            state.set_progress_value(i)
+            if state.is_interruption_requested():
+                raise InterruptException
+
+        state.set_status("Calculating...")
+        kwargs = {"axis": 1 - axis, "impute": True, "callback": callback}
+        if metric.supports_normalization and normalized_dist:
+            kwargs["normalize"] = True
+        return metric(data, **kwargs)
+
+
+class OWDistances(OWWidget, ConcurrentWidgetMixin):
     name = "Distances"
     description = "Compute a matrix of pairwise distances."
     icon = "icons/Distance.svg"
@@ -40,14 +65,14 @@ class Outputs:
 
     settings_version = 2
 
-    axis = settings.Setting(0)        # type: int
-    metric_idx = settings.Setting(0)  # type: int
+    axis = Setting(0)        # type: int
+    metric_idx = Setting(0)  # type: int
 
     #: Use normalized distances if the metric supports it.
     #: The default is `True`, expect when restoring from old pre v2 settings
     #: (see `migrate_settings`).
-    normalized_dist = settings.Setting(True)  # type: bool
-    autocommit = settings.Setting(True)       # type: bool
+    normalized_dist = Setting(True)  # type: bool
+    autocommit = Setting(True)       # type: bool
 
     want_main_area = False
     buttons_area_orientation = Qt.Vertical
@@ -65,13 +90,15 @@ class Warning(OWWidget.Warning):
         imputing_data = Msg("Missing values were imputed")
 
     def __init__(self):
-        super().__init__()
+        OWWidget.__init__(self)
+        ConcurrentWidgetMixin.__init__(self)
 
         self.data = None
 
-        gui.radioButtons(self.controlArea, self, "axis", ["Rows", "Columns"],
-                         box="Distances between", callback=self._invalidate
-                        )
+        gui.radioButtons(
+            self.controlArea, self, "axis", ["Rows", "Columns"],
+            box="Distances between", callback=self._invalidate
+        )
         box = gui.widgetBox(self.controlArea, "Distance Metric")
         self.metrics_combo = gui.comboBox(
             box, self, "metric_idx",
@@ -93,6 +120,7 @@ def __init__(self):
     @Inputs.data
     @check_sql_input
     def set_data(self, data):
+        self.cancel()
         self.data = data
         self.refresh_metrics()
         self.unconditional_commit()
@@ -106,8 +134,7 @@ def refresh_metrics(self):
     def commit(self):
         # pylint: disable=invalid-sequence-index
         metric = METRICS[self.metric_idx][1]
-        dist = self.compute_distances(metric, self.data)
-        self.Outputs.distances.send(dist)
+        self.compute_distances(metric, self.data)
 
     def compute_distances(self, metric, data):
         def _check_sparse():
@@ -152,22 +179,36 @@ def _fix_missing():
             return True
 
         self.clear_messages()
-        if data is None:
-            return None
-        for check in (_check_sparse,
-                      _fix_discrete, _fix_missing, _fix_nonbinary):
-            if not check():
-                return None
-        try:
-            if metric.supports_normalization and self.normalized_dist:
-                return metric(data, axis=1 - self.axis, impute=True,
-                              normalize=True)
-            else:
-                return metric(data, axis=1 - self.axis, impute=True)
-        except ValueError as e:
-            self.Error.distances_value_error(e)
-        except MemoryError:
+        if data is not None:
+            for check in (_check_sparse, _fix_discrete,
+                          _fix_missing, _fix_nonbinary):
+                if not check():
+                    data = None
+                    break
+
+        self.start(DistanceRunner.run, data, metric,
+                   self.normalized_dist, self.axis)
+
+    def on_partial_result(self, _):
+        pass
+
+    def on_done(self, result: Orange.misc.DistMatrix):
+        assert isinstance(result, Orange.misc.DistMatrix) or result is None
+        self.Outputs.distances.send(result)
+
+    def on_exception(self, ex):
+        if isinstance(ex, ValueError):
+            self.Error.distances_value_error(ex)
+        elif isinstance(ex, MemoryError):
             self.Error.distances_memory_error()
+        elif isinstance(ex, InterruptException):
+            pass
+        else:
+            raise ex
+
+    def onDeleteWidget(self):
+        self.shutdown()
+        super().onDeleteWidget()
 
     def _invalidate(self):
         self.commit()


Test patch:
diff --git a/Orange/distance/tests/test_distance.py b/Orange/distance/tests/test_distance.py
--- a/Orange/distance/tests/test_distance.py
+++ b/Orange/distance/tests/test_distance.py
@@ -933,5 +933,51 @@ def test_hamming_row_secondary_data(self):
              [1, 2 / 3]])
 
 
+class TestHelperFunctions(unittest.TestCase):
+    # pylint: disable=protected-access, no-self-use
+    def test_interruptable_dot(self):
+        dot = distance.distance._interruptible_dot
+        k, m, n = 20, 30, 40
+        a = np.random.randint(10, size=(m, n))
+        b = np.random.randint(10, size=(n, k))
+
+        c = dot(a, b, step=10)
+        np.testing.assert_array_equal(c, np.dot(a, b))
+
+    def test_interruptable_dot_list(self):
+        dot = distance.distance._interruptible_dot
+        a = [[1, 2, 3], [1, 2, 3]]
+        b = [3, 2, 1]
+        c = dot(a, b, step=10)
+        np.testing.assert_array_equal(c, np.dot(a, b))
+
+    def test_interruptable_dot_scalar(self):
+        dot = distance.distance._interruptible_dot
+        a = 2
+        b = 3
+        c = dot(a, b, step=10)
+        np.testing.assert_array_equal(c, np.dot(a, b))
+
+    def test_interruptable_sqrt(self):
+        sqrt_ = distance.distance._interruptible_sqrt
+        n = 100
+        a = np.random.randint(10, size=(n, n))
+
+        new_a = sqrt_(a, step=10)
+        np.testing.assert_array_equal(new_a, np.sqrt(a))
+
+    def test_interruptable_sqrt_list(self):
+        sqrt_ = distance.distance._interruptible_sqrt
+        l = [1, 2, 3]
+        new_l = sqrt_(l, step=10)
+        np.testing.assert_array_equal(new_l, np.sqrt(l))
+
+    def test_interruptable_sqrt_scalar(self):
+        sqrt_ = distance.distance._interruptible_sqrt
+        i = 9
+        new_i = sqrt_(i, step=10)
+        np.testing.assert_array_equal(new_i, np.sqrt(i))
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/Orange/widgets/unsupervised/tests/test_owdistances.py b/Orange/widgets/unsupervised/tests/test_owdistances.py
--- a/Orange/widgets/unsupervised/tests/test_owdistances.py
+++ b/Orange/widgets/unsupervised/tests/test_owdistances.py
@@ -1,15 +1,70 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring
+import unittest
 from unittest.mock import Mock
 
 import numpy as np
 
-from Orange.data import Table, Domain
 from Orange import distance
-from Orange.widgets.unsupervised.owdistances import OWDistances, METRICS
+from Orange.data import Table, Domain
+from Orange.misc import DistMatrix
+from Orange.widgets.unsupervised.owdistances import OWDistances, METRICS, \
+    DistanceRunner
 from Orange.widgets.tests.base import WidgetTest
 
 
+class TestDistanceRunner(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.iris = Table("iris")[::5]
+        cls.iris.X[0, 2] = np.nan
+        cls.iris.X[1, 3] = np.nan
+        cls.iris.X[2, 1] = np.nan
+        cls.zoo = Table("zoo")[::5]
+        cls.zoo.X[0, 2] = np.nan
+        cls.zoo.X[1, 3] = np.nan
+        cls.zoo.X[2, 1] = np.nan
+
+    def test_run(self):
+        state = Mock()
+        state.is_interruption_requested = Mock(return_value=False)
+        for name, metric in METRICS:
+            data = self.iris
+            if not metric.supports_missing or name == "Bhattacharyya":
+                data = distance.impute(data)
+            elif name == "Jaccard":
+                data = self.zoo
+
+            # between rows, normalized
+            dist1 = DistanceRunner.run(data, metric, True, 0, state)
+            dist2 = metric(data, axis=1, impute=True, normalize=True)
+            self.assertDistMatrixEqual(dist1, dist2)
+
+            # between rows, not normalized
+            dist1 = DistanceRunner.run(data, metric, False, 0, state)
+            dist2 = metric(data, axis=1, impute=True, normalize=False)
+            self.assertDistMatrixEqual(dist1, dist2)
+
+            # between columns, normalized
+            dist1 = DistanceRunner.run(data, metric, True, 1, state)
+            dist2 = metric(data, axis=0, impute=True, normalize=True)
+            self.assertDistMatrixEqual(dist1, dist2)
+
+            # between columns, not normalized
+            dist1 = DistanceRunner.run(data, metric, False, 1, state)
+            dist2 = metric(data, axis=0, impute=True, normalize=False)
+            self.assertDistMatrixEqual(dist1, dist2)
+
+    def assertDistMatrixEqual(self, dist1, dist2):
+        self.assertIsInstance(dist1, DistMatrix)
+        self.assertIsInstance(dist2, DistMatrix)
+        self.assertEqual(dist1.axis, dist2.axis)
+        self.assertEqual(dist1.row_items, dist2.row_items)
+        self.assertEqual(dist1.col_items, dist2.col_items)
+        np.testing.assert_array_almost_equal(dist1, dist2)
+
+
 class TestOWDistances(WidgetTest):
     @classmethod
     def setUpClass(cls):
@@ -27,14 +82,14 @@ def test_distance_combo(self):
         for i, (_, metric) in enumerate(METRICS):
             self.widget.metrics_combo.activated.emit(i)
             self.widget.metrics_combo.setCurrentIndex(i)
-            self.send_signal(self.widget.Inputs.data, self.iris)
+            self.wait_until_stop_blocking()
             if metric.supports_normalization:
                 expected = metric(self.iris, normalize=self.widget.normalized_dist)
             else:
                 expected = metric(self.iris)
 
             if metric is not distance.Jaccard:
-                np.testing.assert_array_equal(
+                np.testing.assert_array_almost_equal(
                     expected, self.get_output(self.widget.Outputs.distances))
 
     def test_error_message(self):
@@ -42,8 +97,10 @@ def test_error_message(self):
         data is removed from input"""
         self.widget.metric_idx = 2
         self.send_signal(self.widget.Inputs.data, self.iris)
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_continuous_features.is_shown())
         self.send_signal(self.widget.Inputs.data, self.titanic)
+        self.wait_until_stop_blocking()
         self.assertTrue(self.widget.Error.no_continuous_features.is_shown())
         self.send_signal(self.widget.Inputs.data, None)
         self.assertFalse(self.widget.Error.no_continuous_features.is_shown())
@@ -53,32 +110,39 @@ def test_jaccard_messages(self):
             if name == "Jaccard":
                 break
         self.send_signal(self.widget.Inputs.data, self.iris)
+        self.wait_until_stop_blocking()
         self.assertTrue(self.widget.Error.no_binary_features.is_shown())
         self.assertFalse(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         self.send_signal(self.widget.Inputs.data, None)
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertFalse(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         self.send_signal(self.widget.Inputs.data, self.titanic)
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertTrue(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         self.send_signal(self.widget.Inputs.data, None)
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertFalse(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         self.send_signal(self.widget.Inputs.data, self.titanic)
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertTrue(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         dom = self.titanic.domain
         dom = Domain(dom.attributes[1:], dom.class_var)
         self.send_signal(self.widget.Inputs.data, self.titanic.transform(dom))
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertFalse(self.widget.Warning.ignoring_nonbinary.is_shown())
 
         self.send_signal(self.widget.Inputs.data, Table("heart_disease"))
+        self.wait_until_stop_blocking()
         self.assertFalse(self.widget.Error.no_binary_features.is_shown())
         self.assertFalse(self.widget.Warning.ignoring_discrete.is_shown())
 
@@ -93,10 +157,12 @@ def test_too_big_array(self):
 
         mock = Mock(side_effect=ValueError)
         self.widget.compute_distances(mock, self.iris)
+        self.wait_until_stop_blocking()
         self.assertTrue(self.widget.Error.distances_value_error.is_shown())
 
         mock = Mock(side_effect=MemoryError)
         self.widget.compute_distances(mock, self.iris)
+        self.wait_until_stop_blocking()
         self.assertEqual(len(self.widget.Error.active), 1)
         self.assertTrue(self.widget.Error.distances_memory_error.is_shown())
 
@@ -110,5 +176,10 @@ def test_negative_values_bhattacharyya(self):
             if metric == distance.Bhattacharyya:
                 break
         self.send_signal(self.widget.Inputs.data, self.iris)
+        self.wait_until_stop_blocking()
         self.assertTrue(self.widget.Error.distances_value_error.is_shown())
         self.iris.X[0, 0] *= -1
+
+
+if __name__ == "__main__":
+    unittest.main()