Repository: statsmodels/statsmodels

Issue:
Mosaicplot errors on empty groups with pandas categorical data
#### Describe the bug

When attempting a mosaicplot from a pandas dataframe, If one of the categories has zero values, mosaicplot fails with `ValueError: Both *x_transform* and *y_transform* must be 2D affine transforms` if one of the columns referenced is a pandas Category type.

#### Code Sample

```python
import pandas as pd
from statsmodels.graphics.mosaicplot import mosaic
animal = ['dog', 'dog', 'dog', 'cat', 'dog', 'cat', 'cat', 'dog', 'dog', 'cat']
size = ['medium','large','medium','medium','medium','medium','large','large','large','small']
testdata = pd.DataFrame({'animal': animal, 'size': size})
testdata['size'] = pd.Categorical(testdata['size'], categories=['small','medium','large'])
testdata = testdata.sort_values('size')
mosaic(testdata, ['animal', 'size']);
```
which results in the following warnings and errors:

```
/home/nbuser/anaconda3_501/lib/python3.6/site-packages/statsmodels/graphics/mosaicplot.py:40: RuntimeWarning: invalid value encountered in less
  if np.any(proportion < 0):
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
posx and posy should be finite values
/home/nbuser/anaconda3_501/lib/python3.6/site-packages/matplotlib/transforms.py:402: RuntimeWarning: invalid value encountered in double_scalars
  return points[1, 0] - points[0, 0]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~/anaconda3_501/lib/python3.6/site-packages/IPython/core/formatters.py in __call__(self, obj)
    339                 pass
    340             else:
--> 341                 return printer(obj)
    342             # Finally look for special method names
    343             method = get_real_method(obj, self.print_method)

~/anaconda3_501/lib/python3.6/site-packages/IPython/core/pylabtools.py in <lambda>(fig)
    242 
    243     if 'png' in formats:
--> 244         png_formatter.for_type(Figure, lambda fig: print_figure(fig, 'png', **kwargs))
    245     if 'retina' in formats or 'png2x' in formats:
    246         png_formatter.for_type(Figure, lambda fig: retina_figure(fig, **kwargs))

~/anaconda3_501/lib/python3.6/site-packages/IPython/core/pylabtools.py in print_figure(fig, fmt, bbox_inches, **kwargs)
    126 
    127     bytes_io = BytesIO()
--> 128     fig.canvas.print_figure(bytes_io, **kw)
    129     data = bytes_io.getvalue()
    130     if fmt == 'svg':

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/backend_bases.py in print_figure(self, filename, dpi, facecolor, edgecolor, orientation, format, bbox_inches, **kwargs)
   2051                     bbox_artists = kwargs.pop("bbox_extra_artists", None)
   2052                     bbox_inches = self.figure.get_tightbbox(renderer,
-> 2053                             bbox_extra_artists=bbox_artists)
   2054                     pad = kwargs.pop("pad_inches", None)
   2055                     if pad is None:

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/figure.py in get_tightbbox(self, renderer, bbox_extra_artists)
   2271 
   2272         for a in artists:
-> 2273             bbox = a.get_tightbbox(renderer)
   2274             if bbox is not None and (bbox.width != 0 or bbox.height != 0):
   2275                 bb.append(bbox)

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/axes/_base.py in get_tightbbox(self, renderer, call_axes_locator, bbox_extra_artists)
   4220 
   4221         for a in bbox_artists:
-> 4222             bbox = a.get_tightbbox(renderer)
   4223             if bbox is not None and (bbox.width != 0 or bbox.height != 0):
   4224                 bb.append(bbox)

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/artist.py in get_tightbbox(self, renderer)
    269         """
    270 
--> 271         bbox = self.get_window_extent(renderer)
    272         if self.get_clip_on():
    273             clip_box = self.get_clip_box()

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/spines.py in get_window_extent(self, renderer)
    151         # correct:
    152         self._adjust_location()
--> 153         return super().get_window_extent(renderer=renderer)
    154 
    155     def get_path(self):

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/patches.py in get_window_extent(self, renderer)
    546 
    547     def get_window_extent(self, renderer=None):
--> 548         return self.get_path().get_extents(self.get_transform())
    549 
    550 

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/path.py in get_extents(self, transform)
    526         path = self
    527         if transform is not None:
--> 528             transform = transform.frozen()
    529             if not transform.is_affine:
    530                 path = self.transformed(transform)

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/transforms.py in frozen(self)
   2191 
   2192     def frozen(self):
-> 2193         return blended_transform_factory(self._x.frozen(), self._y.frozen())
   2194     frozen.__doc__ = Transform.frozen.__doc__
   2195 

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/transforms.py in blended_transform_factory(x_transform, y_transform)
   2338     if (isinstance(x_transform, Affine2DBase)
   2339         and isinstance(y_transform, Affine2DBase)):
-> 2340         return BlendedAffine2D(x_transform, y_transform)
   2341     return BlendedGenericTransform(x_transform, y_transform)
   2342 

~/anaconda3_501/lib/python3.6/site-packages/matplotlib/transforms.py in __init__(self, x_transform, y_transform, **kwargs)
   2279         is_correct = is_affine and is_separable
   2280         if not is_correct:
-> 2281             raise ValueError("Both *x_transform* and *y_transform* must be 2D "
   2282                              "affine transforms")
   2283 

ValueError: Both *x_transform* and *y_transform* must be 2D affine transforms
```
#### Expected Output

I expect the same output as

```python
import pandas as pd
from statsmodels.graphics.mosaicplot import mosaic
animal = ['dog', 'dog', 'dog', 'cat', 'dog', 'cat', 'cat', 'dog', 'dog', 'cat']
size = ['medium','large','medium','medium','medium','medium','large','large','large','small']
testdata = pd.DataFrame({'animal': animal, 'size': size})
mosaic(testdata, ['animal', 'size']);
```
![image](https://user-images.githubusercontent.com/12725310/57275401-cbc2bb00-70e1-11e9-81aa-ad2735c2fea7.png)

But with the `size` categories sorted appropriately. Note that this isn't about the sorting, but the categorical data type from pandas. The same error occurs without ordered categories and the `sort_values` call, but I provided it for context as to when sorted categorical values are useful in this type of plot.

This also doesn't happen if all possible permutations of categories are met, so

```python
import pandas as pd
from statsmodels.graphics.mosaicplot import mosaic
animal = ['dog', 'dog', 'dog', 'cat', 'dog', 'cat', 'cat', 'dog', 'dog', 'cat']
size = ['medium','large','small','medium','medium','medium','large','large','large','small']
testdata = pd.DataFrame({'animal': animal, 'size': size})
testdata['size'] = pd.Categorical(testdata['size'], categories=['small','medium','large'])
testdata = testdata.sort_values('size')
mosaic(testdata, ['animal', 'size']);
```
does work:

![image](https://user-images.githubusercontent.com/12725310/57275423-e09f4e80-70e1-11e9-8541-106b7f0a36a8.png)

#### Output of ``import statsmodels.api as sm; sm.show_versions()``

```
INSTALLED VERSIONS
------------------
Python: 3.6.6.final.0
OS: Linux 4.14.29-linuxkit #1 SMP Thu Mar 22 20:44:45 UTC 2018 x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8

Statsmodels
===========

Installed: 0.9.0 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/statsmodels)

Required Dependencies
=====================

cython: 0.28.5 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/Cython)
numpy: 1.16.2 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/numpy)
scipy: 1.1.0 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/scipy)
pandas: 0.23.4 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/pandas)
    dateutil: 2.7.5 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/dateutil)
patsy: 0.5.1 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/patsy)

Optional Dependencies
=====================

matplotlib: 3.0.0 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/matplotlib)
    backend: module://ipykernel.pylab.backend_inline 
cvxopt: Not installed
joblib: 0.12.5 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/joblib)

Developer Tools
================

IPython: 7.1.1 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/IPython)
    jinja2: 2.10 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/jinja2)
sphinx: 1.8.1 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/sphinx)
    pygments: 2.2.0 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/pygments)
nose: 1.3.7 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages/nose)
pytest: 3.8.2 (/home/nbuser/anaconda3_501/lib/python3.6/site-packages)
virtualenv: Not installed
```


Discussion:

Resolving patch:
diff --git a/statsmodels/graphics/mosaicplot.py b/statsmodels/graphics/mosaicplot.py
--- a/statsmodels/graphics/mosaicplot.py
+++ b/statsmodels/graphics/mosaicplot.py
@@ -330,6 +330,8 @@ def _normalize_dataframe(dataframe, index):
     grouped = data.groupby(index, sort=False)
     counted = grouped[index].count()
     averaged = counted.mean(axis=1)
+    # Fill empty missing with 0, see GH5639
+    averaged = averaged.fillna(0.0)
     return averaged
 
 


Test patch:
diff --git a/statsmodels/graphics/tests/test_mosaicplot.py b/statsmodels/graphics/tests/test_mosaicplot.py
--- a/statsmodels/graphics/tests/test_mosaicplot.py
+++ b/statsmodels/graphics/tests/test_mosaicplot.py
@@ -1,17 +1,19 @@
 from __future__ import division
+
 from statsmodels.compat.python import iterkeys, zip, lrange, iteritems, range
 
-from numpy.testing import assert_, assert_raises
+from collections import OrderedDict
+from io import BytesIO
+from itertools import product
+
+import numpy as np
 import pandas as pd
 import pytest
+from numpy.testing import assert_, assert_raises
 
-# utilities for the tests
-
-from collections import OrderedDict
 from statsmodels.api import datasets
 
-import numpy as np
-from itertools import product
+# utilities for the tests
 
 try:
     import matplotlib.pyplot as plt  # noqa:F401
@@ -428,3 +430,18 @@ def test_default_arg_index(close_figures):
                        'length' : ['long', 'short', 'short', 'long', 'long',
                                    'short']})
     assert_raises(ValueError, mosaic, data=df, title='foobar')
+
+
+def test_missing_category(close_figures):
+    # GH5639
+    animal = ['dog', 'dog', 'dog', 'cat', 'dog', 'cat', 'cat',
+              'dog', 'dog', 'cat']
+    size = ['medium', 'large', 'medium', 'medium', 'medium', 'medium',
+            'large', 'large', 'large', 'small']
+    testdata = pd.DataFrame({'animal': animal, 'size': size})
+    testdata['size'] = pd.Categorical(testdata['size'],
+                                      categories=['small', 'medium', 'large'])
+    testdata = testdata.sort_values('size')
+    fig, _ = mosaic(testdata, ['animal', 'size'])
+    bio = BytesIO()
+    fig.savefig(bio, format='png')