Repository: biolab/orange3

Issue:
Distributions: unequal bin width
<!-- 
Thanks for taking the time to report a bug!
If you're raising an issue about an add-on (i.e., installed via Options > Add-ons), raise an issue in the relevant add-on's issue tracker instead. See: https://github.com/biolab?q=orange3
To fix the bug, we need to be able to reproduce it. Please answer the following questions to the best of your ability.
-->


- [ ] **What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->
Distributions in some cases shows unequal bin width, making the histogram confusing. I would expect all bins (bars) to be of equal width.

<img width="754" alt="Screen Shot 2020-12-16 at 10 02 49" src="https://user-images.githubusercontent.com/12524972/102327428-02330e00-3f86-11eb-862b-02983d155f5b.png">





- [ ] **How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->
File (housing) - Distributions. Select RAD column with bin width at minimum. It seems to happens when there are a lot of integer-like floats and only some decimal data, e.g. [20.0, 20.0, 20.5, 21.0, 21.0, 21.0, 21.6, 24.0, 24.0., 24.0].




- [ ] **What's your environment?**
<!-- To find your Orange version, see "Help → About → Version" or `Orange.version.full_version` in code -->
- Operating system: OSX High Sierra
- Orange version: 3.28.dev
- How you installed Orange: conda/pip



Discussion:
It is not entirely clear what should this visualization look like.

The variable has values 1, 2, 3, 4, 5, 6, 7, 8, 24. For variables with so few distinct values, the widget can also assign have one bin for each value. But what is the bin width in this case? (Note that the x axis is **not** categorical.)
Or, in general, consider a variable whose distinct values are [1.5, 1.8, 2, 2.34, 10]. With one value per bin, what is the expected bin width?

I would tend to say this works as expected, but with unexpected results. We can add an information icon, explaining that each bin represents one unique value.
This doesn't happen only for single value per bin. I have a dataset with 56108 instances. The default visualization for a certain variable creates the following bins:
- (, 48.67) (5383 instances)
- [48.67, 49) (143 instances)
- [49, 50) (7426 instances)
- [50, 50.33) (246 instances)
- [50.33, 50.5) (75 instances)
- [50.5, 50.67) (2723 instances)
- [50.67, 51) (70 instances)
- [51, 52) (18411 instances)
- [52,) (3979 instances)

I would expect the following default bins:
(, 49), [49, 50), [50, 51), [51, 52), [52,)


Alternatively, I would expect the bin not to stretch more than the other bins. If a bin represents a single value, then its width should be the same as other bins. For RAD, the first seven bins should be a single large bin, or the final two bins should be two narrow bins with empty space in between. No? 

Resolving patch:
diff --git a/Orange/widgets/visualize/owdistributions.py b/Orange/widgets/visualize/owdistributions.py
--- a/Orange/widgets/visualize/owdistributions.py
+++ b/Orange/widgets/visualize/owdistributions.py
@@ -668,8 +668,8 @@ def _disc_split_plot(self):
     def _cont_plot(self):
         self._set_cont_ticks()
         data = self.valid_data
-        y, x = np.histogram(
-            data, bins=self.binnings[self.number_of_bins].thresholds)
+        binning = self.binnings[self.number_of_bins]
+        y, x = np.histogram(data, bins=binning.thresholds)
         total = len(data)
         colors = [QColor(0, 128, 255)]
         if self.fitted_distribution:
@@ -677,15 +677,20 @@ def _cont_plot(self):
 
         tot_freq = 0
         lasti = len(y) - 1
+        width = np.min(x[1:] - x[:-1])
+        unique = self.number_of_bins == 0 and binning.width is None
+        xoff = -width / 2 if unique else 0
         for i, (x0, x1), freq in zip(count(), zip(x, x[1:]), y):
             tot_freq += freq
-            desc = self.str_int(x0, x1, not i, i == lasti)
+            desc = self.str_int(x0, x1, not i, i == lasti, unique)
             tooltip = \
                 "<p style='white-space:pre;'>" \
                 f"<b>{escape(desc)}</b>: " \
                 f"{freq} ({100 * freq / total:.2f} %)</p>"
+            bar_width = width if unique else x1 - x0
             self._add_bar(
-                x0, x1 - x0, 0, [tot_freq if self.cumulative_distr else freq],
+                x0 + xoff, bar_width, 0,
+                [tot_freq if self.cumulative_distr else freq],
                 colors, stacked=False, expanded=False, tooltip=tooltip,
                 desc=desc, hidden=self.hide_bars)
 
@@ -697,8 +702,8 @@ def _cont_plot(self):
     def _cont_split_plot(self):
         self._set_cont_ticks()
         data = self.valid_data
-        _, bins = np.histogram(
-            data, bins=self.binnings[self.number_of_bins].thresholds)
+        binning = self.binnings[self.number_of_bins]
+        _, bins = np.histogram(data, bins=binning.thresholds)
         gvalues = self.cvar.values
         varcolors = [QColor(*col) for col in self.cvar.colors]
         if self.fitted_distribution:
@@ -720,12 +725,17 @@ def _cont_split_plot(self):
         tot_freqs = np.zeros(len(ys))
 
         lasti = len(ys[0]) - 1
+        width = np.min(bins[1:] - bins[:-1])
+        unique = self.number_of_bins == 0 and binning.width is None
+        xoff = -width / 2 if unique else 0
         for i, x0, x1, freqs in zip(count(), bins, bins[1:], zip(*ys)):
             tot_freqs += freqs
             plotfreqs = tot_freqs.copy() if self.cumulative_distr else freqs
-            desc = self.str_int(x0, x1, not i, i == lasti)
+            desc = self.str_int(x0, x1, not i, i == lasti, unique)
+            bar_width = width if unique else x1 - x0
             self._add_bar(
-                x0, x1 - x0, 0 if self.stacked_columns else 0.1, plotfreqs,
+                x0 + xoff, bar_width, 0 if self.stacked_columns else 0.1,
+                plotfreqs,
                 gcolors, stacked=self.stacked_columns, expanded=self.show_probs,
                 hidden=self.hide_bars,
                 tooltip=self._split_tooltip(
@@ -899,12 +909,12 @@ def min_var_resolution(var):
             return 0
         return 10 ** -var.number_of_decimals
 
-    def str_int(self, x0, x1, first, last):
+    def str_int(self, x0, x1, first, last, unique=False):
         var = self.var
         sx0, sx1 = var.repr_val(x0), var.repr_val(x1)
         if self.cumulative_distr:
             return f"{var.name} < {sx1}"
-        elif first and last:
+        elif first and last or unique:
             return f"{var.name} = {sx0}"
         elif first:
             return f"{var.name} < {sx1}"


Test patch: