Repository: biolab/orange3

Issue:
Values of numeric variables should sometimes show more decimals
`ContinuousVariable.str_val` shows `number_of_decimals` decimals. This is not OK when the value being printed is, for instance, an average of otherwise discrete values, like the average age in case when the original age was given in years, as integers.

`str_val` could easily detect such situations: if `abs(round(x, var.number_of_decimals) - x)` is larger than, for instance, `10 ** (-number_of_decimals - 5)`, it would use `number_of_decimals + 2` decimals.


Discussion:

Resolving patch:
diff --git a/Orange/data/variable.py b/Orange/data/variable.py
--- a/Orange/data/variable.py
+++ b/Orange/data/variable.py
@@ -529,6 +529,7 @@ def __init__(self, name="", number_of_decimals=None, compute_value=None, *, spar
         three, but adjusted at the first call of :obj:`to_val`.
         """
         super().__init__(name, compute_value, sparse=sparse)
+        self._max_round_diff = 0
         self.number_of_decimals = number_of_decimals
 
     @property
@@ -553,6 +554,7 @@ def number_of_decimals(self, x):
             return
 
         self._number_of_decimals = x
+        self._max_round_diff = 10 ** (-x - 6)
         self.adjust_decimals = 0
         if self._number_of_decimals <= MAX_NUM_OF_DECIMALS:
             self._format_str = "%.{}f".format(self.number_of_decimals)
@@ -580,6 +582,10 @@ def repr_val(self, val):
         """
         if isnan(val):
             return "?"
+        if self.format_str != "%g" \
+                and abs(round(val, self._number_of_decimals) - val) \
+                > self._max_round_diff:
+            return f"{val:.{self._number_of_decimals + 2}f}"
         return self._format_str % val
 
     str_val = repr_val
@@ -593,6 +599,7 @@ def copy(self, compute_value=None, *, name=None, **kwargs):
             var.number_of_decimals = number_of_decimals
         else:
             var._number_of_decimals = self._number_of_decimals
+            var._max_round_diff = self._max_round_diff
             var.adjust_decimals = self.adjust_decimals
             var.format_str = self._format_str
         return var
diff --git a/benchmark/bench_save.py b/benchmark/bench_save.py
new file mode 100644
--- /dev/null
+++ b/benchmark/bench_save.py
@@ -0,0 +1,61 @@
+from functools import partial
+import os
+
+import numpy as np
+import scipy.sparse
+
+from Orange.data import Table, ContinuousVariable, Domain
+from .base import Benchmark, benchmark
+
+
+def save(table, fn):
+    try:
+        table.save(fn)
+    finally:
+        os.remove(fn)
+
+
+class BenchSave(Benchmark):
+
+    def setup_dense(self, rows, cols, varkwargs=None):
+        if varkwargs is None:
+            varkwargs = {}
+        self.table = Table.from_numpy(  # pylint: disable=W0201
+            Domain([ContinuousVariable(str(i), **varkwargs) for i in range(cols)]),
+            np.random.RandomState(0).rand(rows, cols))
+
+    def setup_sparse(self, rows, cols, varkwargs=None):
+        if varkwargs is None:
+            varkwargs = {}
+        sparse = scipy.sparse.rand(rows, cols, density=0.01, format='csr', random_state=0)
+        self.table = Table.from_numpy(  # pylint: disable=W0201
+            Domain([ContinuousVariable(str(i), sparse=True, **varkwargs) for i in range(cols)]),
+            sparse)
+
+    @benchmark(setup=partial(setup_dense, rows=100, cols=10))
+    def bench_print_dense(self):
+        str(self.table)
+
+    @benchmark(setup=partial(setup_dense, rows=100, cols=10,
+                             varkwargs={"number_of_decimals": 2}))
+    def bench_print_dense_decimals(self):
+        str(self.table)
+
+    @benchmark(setup=partial(setup_sparse, rows=100, cols=10), number=5)
+    def bench_print_sparse(self):
+        str(self.table)
+
+    @benchmark(setup=partial(setup_sparse, rows=100, cols=10,
+                             varkwargs={"number_of_decimals": 2}),
+               number=5)
+    def bench_print_sparse_decimals(self):
+        str(self.table)
+
+    @benchmark(setup=partial(setup_dense, rows=100, cols=100))
+    def bench_save_tab(self):
+        save(self.table, "temp_save.tab")
+
+    @benchmark(setup=partial(setup_dense, rows=100, cols=100,
+                             varkwargs={"number_of_decimals": 2}))
+    def bench_save_tab_decimals(self):
+        save(self.table, "temp_save.tab")
diff --git a/benchmark/bench_variable.py b/benchmark/bench_variable.py
new file mode 100644
--- /dev/null
+++ b/benchmark/bench_variable.py
@@ -0,0 +1,19 @@
+from Orange.data import ContinuousVariable
+from .base import Benchmark, benchmark
+
+
+class BenchContinuous(Benchmark):
+
+    # pylint: disable=no-self-use
+    @benchmark()
+    def bench_str_val_decimals(self):
+        a = ContinuousVariable("a", 4)
+        for _ in range(1000):
+            a.str_val(1.23456)
+
+    # pylint: disable=no-self-use
+    @benchmark()
+    def bench_str_val_g(self):
+        a = ContinuousVariable("a")
+        for _ in range(1000):
+            a.str_val(1.23456)


Test patch:
diff --git a/Orange/data/tests/test_variable.py b/Orange/data/tests/test_variable.py
--- a/Orange/data/tests/test_variable.py
+++ b/Orange/data/tests/test_variable.py
@@ -498,23 +498,42 @@ def test_make(self):
 
     def test_decimals(self):
         a = ContinuousVariable("a", 4)
-        self.assertEqual(a.str_val(4.654321), "4.6543")
-        self.assertEqual(a.str_val(4.654321654321), "4.6543")
+        self.assertEqual(a.str_val(4.6543), "4.6543")
+        self.assertEqual(a.str_val(4.25), "4.2500")
         self.assertEqual(a.str_val(Unknown), "?")
         a = ContinuousVariable("a", 5)
         self.assertEqual(a.str_val(0.000000000001), "0.00000")
         a = ContinuousVariable("a", 10)
         self.assertEqual(a.str_val(0.000000000001), "1e-12")
 
+    def test_more_decimals(self):
+        a = ContinuousVariable("a", 0)
+        self.assertEqual(a.str_val(4), "4")
+        self.assertEqual(a.str_val(4.1234), "4.12")
+
+        a = ContinuousVariable("a", 2)
+        self.assertEqual(a.str_val(4), "4.00")
+        self.assertEqual(a.str_val(4.25), "4.25")
+        self.assertEqual(a.str_val(4.1234123), "4.1234")
+
+        for cca4 in (4 + 1e-9, 4 - 1e-9):
+            assert cca4 != 4
+            self.assertEqual(a.str_val(cca4), "4.00")
+
     def test_adjust_decimals(self):
+        # Default is 3 decimals, but format is %g
         a = ContinuousVariable("a")
+        self.assertEqual(a.str_val(5), "5")
         self.assertEqual(a.str_val(4.65432), "4.65432")
+
+        # Change to no decimals
         a.val_from_str_add("5")
-        self.assertEqual(a.str_val(4.65432), "5")
+        self.assertEqual(a.str_val(5), "5")
+
+        # Change to two decimals
         a.val_from_str_add("  5.12    ")
-        self.assertEqual(a.str_val(4.65432), "4.65")
-        a.val_from_str_add("5.1234")
-        self.assertEqual(a.str_val(4.65432), "4.6543")
+        self.assertEqual(a.str_val(4.65), "4.65")
+        self.assertEqual(a.str_val(5), "5.00")
 
     def varcls_modified(self, name):
         var = super().varcls_modified(name)
diff --git a/Orange/tests/test_discretize.py b/Orange/tests/test_discretize.py
--- a/Orange/tests/test_discretize.py
+++ b/Orange/tests/test_discretize.py
@@ -140,13 +140,17 @@ def test_create_discretized_var_formatting(self):
 
         dvar = discretize.Discretizer.create_discretized_var(
             self.var, [10.1234])
-        self.assertEqual(dvar.values, ("< 10.1", "≥ 10.1"))
+        self.assertEqual(dvar.values, ("< 10.123", "≥ 10.123"))
 
         self.var.number_of_decimals = 3
 
+        dvar = discretize.Discretizer.create_discretized_var(
+            self.var, [5, 10.25])
+        self.assertEqual(dvar.values, ("< 5", "5 - 10.25", "≥ 10.25"))
+
         dvar = discretize.Discretizer.create_discretized_var(
             self.var, [5, 10.1234])
-        self.assertEqual(dvar.values, ("< 5", "5 - 10.123", "≥ 10.123"))
+        self.assertEqual(dvar.values, ("< 5", "5 - 10.1234", "≥ 10.1234"))
 
     def test_discretizer_computation(self):
         dvar = discretize.Discretizer.create_discretized_var(