Repository: mwaskom/seaborn

Issue:
histplot with categorical values crashes with missing data, though numerical values work fine
Not sure if this is intended behaviour, but it caught me out due to the difference in handling numerical/categorical data. I note that drawing histograms of categorical data is labelled as experimental, so ignore/close if that explains it.

With numerical data `histplot` ignores NaN and plots the other values, this is the behaviour I would expect:

```
import numpy as np
import seaborn as sns

sns.histplot(
    [1.1, 1.2, 1.3, 1.4, np.nan]
)
```

but with categorical data it crashes:
```
import numpy as np
import seaborn as sns

sns.histplot(
    ['foo', 'foo', 'bar', np.nan]
)

# output
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/matplotlib/axis.py in convert_units(self, x)
   1519         try:
-> 1520             ret = self.converter.convert(x, self.units, self)
   1521         except Exception as e:

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/matplotlib/category.py in convert(value, unit, axis)
     60         # force an update so it also does type checking
---> 61         unit.update(values)
     62         return np.vectorize(unit._mapping.__getitem__, otypes=[float])(values)

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/matplotlib/category.py in update(self, data)
    210             # OrderedDict just iterates over unique values in data.
--> 211             cbook._check_isinstance((str, bytes), value=val)
    212             if convertible:

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/matplotlib/cbook/__init__.py in _check_isinstance(_types, **kwargs)
   2234         if not isinstance(v, types):
-> 2235             raise TypeError(
   2236                 "{!r} must be an instance of {}, not a {}".format(

TypeError: 'value' must be an instance of str or bytes, not a float

The above exception was the direct cause of the following exception:

ConversionError                           Traceback (most recent call last)
<ipython-input-61-b132ea7dca6c> in <module>
      2 import seaborn as sns
      3 
----> 4 sns.histplot(
      5     ['foo', 'foo', 'bar', np.nan]
      6 )

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/seaborn/distributions.py in histplot(data, x, y, hue, weights, stat, bins, binwidth, binrange, discrete, cumulative, common_bins, common_norm, multiple, element, fill, shrink, kde, kde_kws, line_kws, thresh, pthresh, pmax, cbar, cbar_ax, cbar_kws, palette, hue_order, hue_norm, color, log_scale, legend, ax, **kwargs)
   1420     if p.univariate:
   1421 
-> 1422         p.plot_univariate_histogram(
   1423             multiple=multiple,
   1424             element=element,

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/seaborn/distributions.py in plot_univariate_histogram(self, multiple, element, fill, common_norm, common_bins, shrink, kde, kde_kws, color, legend, line_kws, estimate_kws, **plot_kws)
    421 
    422         # First pass through the data to compute the histograms
--> 423         for sub_vars, sub_data in self.iter_data("hue", from_comp_data=True):
    424 
    425             # Prepare the relevant data

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/seaborn/_core.py in iter_data(self, grouping_vars, reverse, from_comp_data)
    965 
    966         if from_comp_data:
--> 967             data = self.comp_data
    968         else:
    969             data = self.plot_data

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/seaborn/_core.py in comp_data(self)
   1034                 axis = getattr(ax, f"{var}axis")
   1035 
-> 1036                 comp_var = axis.convert_units(self.plot_data[var])
   1037                 if axis.get_scale() == "log":
   1038                     comp_var = np.log10(comp_var)

~/.virtualenvs/drawingfromdata/lib/python3.8/site-packages/matplotlib/axis.py in convert_units(self, x)
   1520             ret = self.converter.convert(x, self.units, self)
   1521         except Exception as e:
-> 1522             raise munits.ConversionError('Failed to convert value(s) to axis '
   1523                                          f'units: {x!r}') from e
   1524         return ret

ConversionError: Failed to convert value(s) to axis units: 0    foo
1    foo
2    bar
3    NaN
Name: x, dtype: object
```



Discussion:
mwaskom: Should probably mention this in the release notes.
codecov[bot]: # [Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=h1) Report
> Merging [#2417](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=desc) (874847a) into [master](https://codecov.io/gh/mwaskom/seaborn/commit/aad96f8d2e36ceceb82a42b69aa3a8f47ef7210d?el=desc) (aad96f8) will **decrease** coverage by `0.47%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/mwaskom/seaborn/pull/2417/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl)](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #2417      +/-   ##
==========================================
- Coverage   97.36%   96.89%   -0.48%     
==========================================
  Files          17       17              
  Lines        6078     6051      -27     
==========================================
- Hits         5918     5863      -55     
- Misses        160      188      +28     
```


| [Impacted Files](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [seaborn/\_core.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9fY29yZS5weQ==) | `98.12% <100.00%> (-0.16%)` | :arrow_down: |
| [seaborn/\_docstrings.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9fZG9jc3RyaW5ncy5weQ==) | `97.05% <0.00%> (-2.95%)` | :arrow_down: |
| [seaborn/\_statistics.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9fc3RhdGlzdGljcy5weQ==) | `98.40% <0.00%> (-1.60%)` | :arrow_down: |
| [seaborn/regression.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9yZWdyZXNzaW9uLnB5) | `96.51% <0.00%> (-1.28%)` | :arrow_down: |
| [seaborn/matrix.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9tYXRyaXgucHk=) | `94.96% <0.00%> (-0.92%)` | :arrow_down: |
| [seaborn/distributions.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9kaXN0cmlidXRpb25zLnB5) | `95.65% <0.00%> (-0.78%)` | :arrow_down: |
| [seaborn/categorical.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9jYXRlZ29yaWNhbC5weQ==) | `97.58% <0.00%> (-0.42%)` | :arrow_down: |
| [seaborn/utils.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi91dGlscy5weQ==) | `94.82% <0.00%> (-0.40%)` | :arrow_down: |
| [seaborn/axisgrid.py](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree#diff-c2VhYm9ybi9heGlzZ3JpZC5weQ==) | `95.47% <0.00%> (-0.05%)` | :arrow_down: |
| ... and [2 more](https://codecov.io/gh/mwaskom/seaborn/pull/2417/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=footer). Last update [aad96f8...874847a](https://codecov.io/gh/mwaskom/seaborn/pull/2417?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index dc9ade22..4163d56c 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -4,7 +4,9 @@ v0.12.0 (Unreleased)
 
 - |API| |Feature| |Enhancement| TODO (Flesh this out further). Increased flexibility of what can be shown by the internally-calculated errorbars (:pr:2407).
 
-- Made `scipy` an optional dependency and added `pip install seaborn[all]` as a method for ensuring the availability of compatible `scipy` and `statsmodels` libraries. This has a few minor implications for existing code, which are explained in the Github pull request (:pr:`2398`).
+- |Fix| |Enhancement| Improved robustness to missing data, including additional support for the `pd.NA` type (:pr:`2417).
+
+- Made `scipy` an optional dependency and added `pip install seaborn[all]` as a method for ensuring the availability of compatible `scipy` and `statsmodels` libraries at install time. This has a few minor implications for existing code, which are explained in the Github pull request (:pr:`2398`).
 
 - Following `NEP29 <https://numpy.org/neps/nep-0029-deprecation_policy.html>`_, dropped support for Python 3.6 and bumped the minimally-supported versions of the library dependencies.
 
diff --git a/seaborn/_core.py b/seaborn/_core.py
index c582f4e4..b1ede072 100644
--- a/seaborn/_core.py
+++ b/seaborn/_core.py
@@ -1048,10 +1048,16 @@ class VectorPlotter:
                     ax = self.ax
                 axis = getattr(ax, f"{var}axis")
 
-                comp_var = axis.convert_units(self.plot_data[var])
+                # Use the converter assigned to the axis to get a float representation
+                # of the data, passing np.nan or pd.NA through (pd.NA becomes np.nan)
+                with pd.option_context('mode.use_inf_as_null', True):
+                    orig = self.plot_data[var].dropna()
+                comp_col = pd.Series(index=orig.index, dtype=float, name=var)
+                comp_col.loc[orig.index] = pd.to_numeric(axis.convert_units(orig))
+
                 if axis.get_scale() == "log":
-                    comp_var = np.log10(comp_var)
-                comp_data.insert(0, var, comp_var)
+                    comp_col = np.log10(comp_col)
+                comp_data.insert(0, var, comp_col)
 
             self._comp_data = comp_data
 


Test patch:
diff --git a/seaborn/tests/test_core.py b/seaborn/tests/test_core.py
index d55edd4d..e5c91acd 100644
--- a/seaborn/tests/test_core.py
+++ b/seaborn/tests/test_core.py
@@ -1,3 +1,4 @@
+import itertools
 import numpy as np
 import pandas as pd
 import matplotlib as mpl
@@ -24,6 +25,12 @@ from .._core import (
 from ..palettes import color_palette
 
 
+try:
+    from pandas import NA as PD_NA
+except ImportError:
+    PD_NA = None
+
+
 @pytest.fixture(params=[
     dict(x="x", y="y"),
     dict(x="t", y="y"),
@@ -1202,6 +1209,50 @@ class TestVectorPlotter:
             [2, 0, 1, 2],
         )
 
+    @pytest.fixture(
+        params=itertools.product(
+            [None, np.nan, PD_NA],
+            ["numeric", "category", "datetime"]
+        )
+    )
+    @pytest.mark.parametrize(
+        "NA,var_type",
+    )
+    def comp_data_missing_fixture(self, request):
+
+        # This fixture holds the logic for parametrizing
+        # the following test (test_comp_data_missing)
+
+        NA, var_type = request.param
+
+        if NA is None:
+            pytest.skip("No pandas.NA available")
+
+        comp_data = [0, 1, np.nan, 2, np.nan, 1]
+        if var_type == "numeric":
+            orig_data = [0, 1, NA, 2, np.inf, 1]
+        elif var_type == "category":
+            orig_data = ["a", "b", NA, "c", NA, "b"]
+        elif var_type == "datetime":
+            # Use 1-based numbers to avoid issue on matplotlib<3.2
+            # Could simplify the test a bit when we roll off that version
+            comp_data = [1, 2, np.nan, 3, np.nan, 2]
+            numbers = [1, 2, 3, 2]
+
+            orig_data = mpl.dates.num2date(numbers)
+            orig_data.insert(2, NA)
+            orig_data.insert(4, np.inf)
+
+        return orig_data, comp_data
+
+    def test_comp_data_missing(self, comp_data_missing_fixture):
+
+        orig_data, comp_data = comp_data_missing_fixture
+        p = VectorPlotter(variables={"x": orig_data})
+        ax = plt.figure().subplots()
+        p._attach(ax)
+        assert_array_equal(p.comp_data["x"], comp_data)
+
     def test_var_order(self, long_df):
 
         order = ["c", "b", "a"]