Repository: mwaskom/seaborn

Issue:
Categorical variable with numerical classes incorrectly plotted only on y axis
Dear All,


I think I may have found a bug in Seaborn categorical plotting functions.
If a DataFrame column contains numbers, and it is explicitely converted to CategoricalDType, it will be correctly treated as categorical by functions such as boxplot only if it is passed to `x` argument.
If it is instead passed to `y` argument of the plotting function, it will be treated as numerical, and the other variable will be treated as categorical (even though the other variable is obviously numerical).

I am using Python 3.7, Matplotlib 3.3.1, Seaborn 0.11, Numpy 1.19.1, and Pandas 1.1.1 .
I am using Matplotlib backend: 'module://ipykernel.pylab.backend_inline'


Reproducible code:
```python

#Create DataFrame with a numerical column, and a categorical column with number as classes.
n_points = 50

df = pd.DataFrame({"value": np.random.rand(n_points),
                   "class": np.random.randint(0, 2, n_points),
                   })

#Explicitely set `class` column to categorical data type.
df["class"] = df["class"].astype("category")

#If `class` column is passed to `x` argument, the plot is correct: column is treated as categorical.
sns.boxplot(data=df,
            x="class",
            y="value",
            );

#BUG: If `class` column is passed to `y` argument, the plot is incorrect: `class` is treated as numerical!
sns.boxplot(data=df,
            x="value",
            y="class",
            );

```

Please take a look at
[bug_categorical_numbers.pdf](https://github.com/mwaskom/seaborn/files/5371402/bug_categorical_numbers.pdf)
 the attached PDF file with the correct and wrong plots.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/2379?src=pr&el=h1) Report
> Merging [#2379](https://codecov.io/gh/mwaskom/seaborn/pull/2379?src=pr&el=desc) (fef2a96) into [master](https://codecov.io/gh/mwaskom/seaborn/commit/607ded2cd65d98a93b09418b1523de71265f360d?el=desc) (607ded2) will **not change** coverage.
> The diff coverage is `100.00%`.

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

```diff
@@           Coverage Diff           @@
##           master    #2379   +/-   ##
=======================================
  Coverage   96.95%   96.95%           
=======================================
  Files          17       17           
  Lines        6042     6042           
=======================================
  Hits         5858     5858           
  Misses        184      184           
```


| [Impacted Files](https://codecov.io/gh/mwaskom/seaborn/pull/2379?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [seaborn/\_core.py](https://codecov.io/gh/mwaskom/seaborn/pull/2379/diff?src=pr&el=tree#diff-c2VhYm9ybi9fY29yZS5weQ==) | `98.26% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/2379?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/2379?src=pr&el=footer). Last update [607ded2...fef2a96](https://codecov.io/gh/mwaskom/seaborn/pull/2379?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/seaborn/_core.py b/seaborn/_core.py
index cfd2a70d..0531ba63 100644
--- a/seaborn/_core.py
+++ b/seaborn/_core.py
@@ -1177,28 +1177,32 @@ class VectorPlotter:
 
 
 def variable_type(vector, boolean_type="numeric"):
-    """Determine whether a vector contains numeric, categorical, or dateime data.
+    """
+    Determine whether a vector contains numeric, categorical, or datetime data.
 
     This function differs from the pandas typing API in two ways:
 
     - Python sequences or object-typed PyData objects are considered numeric if
       all of their entries are numeric.
     - String or mixed-type data are considered categorical even if not
-      explicitly represented as a :class:pandas.api.types.CategoricalDtype`.
+      explicitly represented as a :class:`pandas.api.types.CategoricalDtype`.
 
     Parameters
     ----------
     vector : :func:`pandas.Series`, :func:`numpy.ndarray`, or Python sequence
         Input data to test.
-    binary_type : 'numeric' or 'categorical'
+    boolean_type : 'numeric' or 'categorical'
         Type to use for vectors containing only 0s and 1s (and NAs).
 
     Returns
     -------
     var_type : 'numeric', 'categorical', or 'datetime'
         Name identifying the type of data in the vector.
-
     """
+    # If a categorical dtype is set, infer categorical
+    if pd.api.types.is_categorical_dtype(vector):
+        return "categorical"
+
     # Special-case all-na data, which is always "numeric"
     if pd.isna(vector).all():
         return "numeric"
@@ -1222,9 +1226,6 @@ def variable_type(vector, boolean_type="numeric"):
     if pd.api.types.is_numeric_dtype(vector):
         return "numeric"
 
-    if pd.api.types.is_categorical_dtype(vector):
-        return "categorical"
-
     if pd.api.types.is_datetime64_dtype(vector):
         return "datetime"
 
diff --git a/seaborn/relational.py b/seaborn/relational.py
index 6ec11abc..b272abcd 100644
--- a/seaborn/relational.py
+++ b/seaborn/relational.py
@@ -475,6 +475,13 @@ class _LinePlotter(_RelationalPlotter):
                 sort_cols = [var for var in sort_vars if var in self.variables]
                 sub_data = sub_data.sort_values(sort_cols)
 
+            # TODO
+            # How to handle NA? We don't want NA to propagate through to the
+            # estimate/CI when some values are present, but we would also like
+            # matplotlib to show "gaps" in the line when all values are missing.
+            # This is straightforward absent aggregation, but complicated with it.
+            sub_data = sub_data.dropna()
+
             # Due to the original design, code below was written assuming that
             # sub_data always has x, y, and units columns, which may be empty.
             # Adding this here to avoid otherwise disruptive changes, but it
diff --git a/seaborn/utils.py b/seaborn/utils.py
index 1d181b35..67cd4703 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -371,7 +371,7 @@ def percentiles(a, pcts, axis=None):
 def ci(a, which=95, axis=None):
     """Return a percentile range from an array of values."""
     p = 50 - which / 2, 50 + which / 2
-    return np.percentile(a, p, axis)
+    return np.nanpercentile(a, p, axis)
 
 
 def sig_stars(p):


Test patch:
diff --git a/seaborn/tests/test_core.py b/seaborn/tests/test_core.py
index 643fdc0a..d60ef6d9 100644
--- a/seaborn/tests/test_core.py
+++ b/seaborn/tests/test_core.py
@@ -1107,6 +1107,9 @@ class TestCoreFunc:
         s = pd.Series([True, False, False])
         assert variable_type(s) == "numeric"
         assert variable_type(s, boolean_type="categorical") == "categorical"
+        s_cat = s.astype("category")
+        assert variable_type(s_cat, boolean_type="categorical") == "categorical"
+        assert variable_type(s_cat, boolean_type="numeric") == "categorical"
 
         s = pd.Series([pd.Timestamp(1), pd.Timestamp(2)])
         assert variable_type(s) == "datetime"
diff --git a/seaborn/tests/test_relational.py b/seaborn/tests/test_relational.py
index 0828eb78..f2df31d8 100644
--- a/seaborn/tests/test_relational.py
+++ b/seaborn/tests/test_relational.py
@@ -1090,6 +1090,23 @@ class TestLinePlotter(Helpers):
         assert np.allclose(line.get_ydata(), expected_data.values)
         assert len(ax.collections) == 1
 
+        # Test that nans do not propagate to means or CIs
+
+        p = _LinePlotter(
+            variables=dict(
+                x=[1, 1, 1, 2, 2, 2, 3, 3, 3],
+                y=[1, 2, 3, 3, np.nan, 5, 4, 5, 6],
+            ),
+            estimator="mean", err_style="band", ci=95, n_boot=100, sort=True,
+        )
+        ax.clear()
+        p.plot(ax, {})
+        line, = ax.lines
+        assert line.get_xdata().tolist() == [1, 2, 3]
+        err_band = ax.collections[0].get_paths()
+        assert len(err_band) == 1
+        assert len(err_band[0].vertices) == 9
+
         p = _LinePlotter(
             data=long_df,
             variables=dict(x="x", y="y", hue="a"),