Repository: biolab/orange3

Issue:
Add chi**2 to boxplot when multiple categorical features are selected
**Is your feature request related to a problem? Please describe.**
Nope not really, just an enhancement.

**Describe the solution you'd like**
The box plot widget outputs student's t-test or ANOVA statistics, including p-values, when selecting a categorical and numerical feature. I really like that; eyeballing is great and all but having the test stats so easily available really enhances the experience. In the same way that that's useful when comparing group means, I think that a Chi**2 statistic would be useful when comparing two categorical features in the same widget. For example using the [census](https://archive.ics.uci.edu/ml/datasets/adult) dataset, we can compare Income (the target) to the "workclass" feature and get this output in the boxplot widget:

![image](https://user-images.githubusercontent.com/4592235/62765244-997d7c80-ba87-11e9-947c-12c95718dbf1.png)

We can calculate the chi**2 statistic and p-value for these 2 features with scipy.stats.chi2_contingency:

```Python
import scipy.stats as ss
import pandas as pd

# instantiate the census data into a dataframe called df

ss.chi2_contingency(pd.crosstab(df.workclass, df.income).values)
```
Output is chi2 statistic: 1045.7085997281692 and p-value: 2.026505431120716e-220

I think it'd be nice to have those printed on the bottom of the box plot widget when comparing categorical features.



**Describe alternatives you've considered**
Calculating it isn't especially arduous so that alternative exists. This is definitely a _nice to have_.

**Additional context**
None necessary I think.



Discussion:
Best described enhancement I've seen in a while! 🌟

Box Plot already computes Chi**2 in Order by relevance, so it should be easy reporting it below the visualization as well. I agree this would be a good feature!
Chi^2 that is used to order the variables by relevance might be incorrect - the number of degrees of freedom seems off. To match the output of `chi2_contingency` it would need to be `sum(observed.shape)-2`. @janezd 

https://github.com/biolab/orange3/blob/42f88945cfd90e7bdf4fcfa974687ce7b4ac5e99/Orange/widgets/visualize/owboxplot.py#L360
> Chi^2 that is used to order the variables by relevance might be incorrect - the number of degrees of freedom seems off. To match the output of `chi2_contingency` it would need to be `sum(observed.shape)-2`.

Maybe `sum(observed.shape) - 1`? This would assume that the sample size is fixed and each data instance "chooses" a cell in the contingency table independently. So we are free to choose `sum(observed.shape) - 1` counts.

We could also use `np.prod(observed.shape - 1)`? This assumes that every data instance chooses the value for each variable independently, but the marginal distributions are fixed.

The current formula may assumes that the marginal distribution of `self.attribute` is fixed (The comment says: `# Chi-square with the given distribution into groups`), but then we should have `len(self.attribute.values) * (n_groups - 1)` degrees of freedom, I think.
Oops, sorry, I got it: you're talking about `ddof`, not `dof`. Your formula is correct - for fixed marginal distributions.

Resolving patch:
diff --git a/Orange/widgets/visualize/owboxplot.py b/Orange/widgets/visualize/owboxplot.py
--- a/Orange/widgets/visualize/owboxplot.py
+++ b/Orange/widgets/visualize/owboxplot.py
@@ -11,7 +11,7 @@
 from AnyQt.QtCore import Qt, QEvent, QRectF, QSize
 
 import scipy.special
-from scipy.stats import f_oneway, chisquare
+from scipy.stats import f_oneway, chi2_contingency
 
 import Orange.data
 from Orange.data.filter import FilterDiscrete, FilterContinuous, Values
@@ -129,8 +129,8 @@ class OWBoxPlot(widget.OWWidget):
     when the user changes display settings or colors.
 
     For discrete attributes, the flow is a bit simpler: the elements are not
-    constructed in advance (by layout_changed). Instead, layout_changed and
-    display_changed call display_changed_disc that draws everything.
+    constructed in advance (by layout_changed). Instead, _display_changed_disc
+    draws everything.
     """
     name = "Box Plot"
     description = "Visualize the distribution of feature values in a box plot."
@@ -343,21 +343,7 @@ def compute_score(attr):
                 groups = [group for group in groups if len(group)]
                 p = f_oneway(*groups)[1] if len(groups) > 1 else 2
             else:
-                # Chi-square with the given distribution into groups
-                # (see degrees of freedom in computation of the p-value)
-                if not attr.values or not group_var.values:
-                    return 2
-                observed = np.array(
-                    contingency.get_contingency(data, group_var, attr))
-                observed = observed[observed.sum(axis=1) != 0, :]
-                observed = observed[:, observed.sum(axis=0) != 0]
-                if min(observed.shape) < 2:
-                    return 2
-                expected = \
-                    np.outer(observed.sum(axis=1), observed.sum(axis=0)) / \
-                    np.sum(observed)
-                p = chisquare(observed.ravel(), f_exp=expected.ravel(),
-                              ddof=n_groups - 1)[1]
+                p = self._chi_square(group_var, attr)[1]
             if math.isnan(p):
                 return 2
             return p
@@ -378,6 +364,18 @@ def compute_score(attr):
             self.reset_attrs(domain)
         self.attribute = attribute
 
+    def _chi_square(self, group_var, attr):
+        # Chi-square with the given distribution into groups
+        if not attr.values or not group_var.values:
+            return 0, 2, 0
+        observed = np.array(
+            contingency.get_contingency(self.dataset, group_var, attr))
+        observed = observed[observed.sum(axis=1) != 0, :]
+        observed = observed[:, observed.sum(axis=0) != 0]
+        if min(observed.shape) < 2:
+            return 0, 2, 0
+        return chi2_contingency(observed)[:3]
+
     def reset_all_data(self):
         self.clear_scene()
         self.stat_test = ""
@@ -479,31 +477,32 @@ def layout_changed(self):
         if self.dataset is None or len(self.conts) == len(self.dist) == 0:
             return
 
-        if not self.is_continuous:
-            self.display_changed_disc()
-            return
+        if self.is_continuous:
+            self.mean_labels = [self.mean_label(stat, attr, lab)
+                                for stat, lab in zip(self.stats, self.label_txts)]
+            self.draw_axis()
+            self.boxes = [self.box_group(stat) for stat in self.stats]
+            self.labels = [self.label_group(stat, attr, mean_lab)
+                           for stat, mean_lab in zip(self.stats, self.mean_labels)]
+            self.attr_labels = [QGraphicsSimpleTextItem(lab)
+                                for lab in self.label_txts]
+            for it in chain(self.labels, self.attr_labels):
+                self.box_scene.addItem(it)
 
-        self.mean_labels = [self.mean_label(stat, attr, lab)
-                            for stat, lab in zip(self.stats, self.label_txts)]
-        self.draw_axis()
-        self.boxes = [self.box_group(stat) for stat in self.stats]
-        self.labels = [self.label_group(stat, attr, mean_lab)
-                       for stat, mean_lab in zip(self.stats, self.mean_labels)]
-        self.attr_labels = [QGraphicsSimpleTextItem(lab)
-                            for lab in self.label_txts]
-        for it in chain(self.labels, self.attr_labels):
-            self.box_scene.addItem(it)
         self.display_changed()
-        self.draw_stat()
 
     def display_changed(self):
-        if self.dataset is None:
+        if self.dataset is None or self.attribute is None:
             return
 
-        if not self.is_continuous:
-            self.display_changed_disc()
-            return
+        if self.is_continuous:
+            self._display_changed_cont()
+        else:
+            self._display_changed_disc()
+        self.draw_stat()
+        self.select_box_items()
 
+    def _display_changed_cont(self):
         self.order = list(range(len(self.stats)))
         criterion = self._sorting_criteria_attrs[self.compare]
         if criterion:
@@ -549,12 +548,10 @@ def display_changed(self):
                    self.scene_width, len(self.stats) * heights + 90)
         self.box_scene.setSceneRect(r)
 
-        self.compute_tests()
-        self.show_posthoc()
-        self.select_box_items()
+        self._compute_tests_cont()
+        self._show_posthoc()
 
-    def display_changed_disc(self):
-        assert not self.is_continuous
+    def _display_changed_disc(self):
         self.clear_scene()
         self.attr_labels = [QGraphicsSimpleTextItem(lab)
                             for lab in self.label_txts_all]
@@ -598,8 +595,7 @@ def display_changed_disc(self):
         self.box_scene.setSceneRect(-self.label_width - 5,
                                     -30 - len(self.boxes) * 40,
                                     self.scene_width, len(self.boxes * 40) + 90)
-        self.stat_test = ""
-        self.select_box_items()
+        self._compute_tests_disc()
 
     def __draw_group_labels(self, y, row):
         """Draw group labels
@@ -673,7 +669,7 @@ def __draw_bars(self, y, bars):
             self.box_scene.addItem(item)
 
     # noinspection PyPep8Naming
-    def compute_tests(self):
+    def _compute_tests_cont(self):
         # The t-test and ANOVA are implemented here since they efficiently use
         # the widget-specific data in self.stats.
         # The non-parametric tests can't do this, so we use statistics.tests
@@ -738,6 +734,16 @@ def stat_ANOVA():
                 t = "" if np.isnan(F) else f"ANOVA: {F:.3f} (p={p:.3f}, N={n})"
         self.stat_test = t
 
+    def _compute_tests_disc(self):
+        if self.group_var is None or self.attribute is None:
+            self.stat_test = ""
+        else:
+            chi, p, dof = self._chi_square(self.group_var, self.attribute)
+            if np.isnan(p):
+                self.stat_test = ""
+            else:
+                self.stat_test = f"χ²: {chi:.2f} (p={p:.3f}, dof={dof})"
+
     def mean_label(self, stat, attr, val_name):
         label = QGraphicsItemGroup()
         t = QGraphicsSimpleTextItem(attr.str_val(stat.mean), label)
@@ -821,7 +827,8 @@ def draw_axis(self):
     def draw_stat(self):
         if self.stat_test:
             label = QGraphicsSimpleTextItem(self.stat_test)
-            label.setPos((self.scene_min_x + self.scene_max_x)/2 - label.boundingRect().width()/2,
+            brect = self.box_scene.sceneRect()
+            label.setPos(brect.center().x() - label.boundingRect().width()/2,
                          8 + self._axis_font.pixelSize()*2)
             label.setFlag(QGraphicsItem.ItemIgnoresTransformations)
             self.box_scene.addItem(label)
@@ -1021,7 +1028,7 @@ def commit(self):
         self.Outputs.annotated_data.send(
             create_annotated_table(self.dataset, selection))
 
-    def show_posthoc(self):
+    def _show_posthoc(self):
         def line(y0, y1):
             it = self.box_scene.addLine(x, y0, x, y1, self._post_line_pen)
             it.setZValue(-100)


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owboxplot.py b/Orange/widgets/visualize/tests/test_owboxplot.py
--- a/Orange/widgets/visualize/tests/test_owboxplot.py
+++ b/Orange/widgets/visualize/tests/test_owboxplot.py
@@ -135,11 +135,20 @@ def select_group(i):
         select_group(1)  # Class
         order_check.setChecked(True)
         self.assertEqual([x.name for x in attributes],
-                         ['thal', 'major vessels colored', 'chest pain',
-                          'ST by exercise', 'max HR', 'exerc ind ang',
-                          'slope peak exc ST', 'gender', 'age', 'rest SBP',
-                          'rest ECG', 'cholesterol',
-                          'fasting blood sugar > 120', 'diameter narrowing'])
+                         ['thal',
+                          'chest pain',
+                          'major vessels colored',
+                          'ST by exercise',
+                          'max HR',
+                          'exerc ind ang',
+                          'slope peak exc ST',
+                          'gender',
+                          'age',
+                          'rest ECG',
+                          'rest SBP',
+                          'cholesterol',
+                          'fasting blood sugar > 120',
+                          'diameter narrowing'])
 
     def test_box_order_when_missing_stats(self):
         self.widget.compare = 1