Repository: mwaskom/seaborn

Issue:
`statsmodels` missing as a dependency
A normal `residplot` on the `mpg` data with `lowess=True` doesn't currently work, because `statsmodels` doesn't exist as a dependency. This is with a fresh `pip install --upgrade seaborn`.
```
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[16], line 1
----> 1 sns.residplot(data=mpg, x='horsepower', y='mpg', lowess=True)

File ~/anaconda3/lib/python3.10/site-packages/seaborn/regression.py:923, in residplot(data, x, y, x_partial, y_partial, lowess, order, robust, dropna, label, color, scatter_kws, line_kws, ax)
    921 scatter_kws = {} if scatter_kws is None else scatter_kws.copy()
    922 line_kws = {} if line_kws is None else line_kws.copy()
--> 923 plotter.plot(ax, scatter_kws, line_kws)
    924 return ax

File ~/anaconda3/lib/python3.10/site-packages/seaborn/regression.py:368, in _RegressionPlotter.plot(self, ax, scatter_kws, line_kws)
    365     self.scatterplot(ax, scatter_kws)
    367 if self.fit_reg:
--> 368     self.lineplot(ax, line_kws)
    370 # Label the axes
    371 if hasattr(self.x, "name"):

File ~/anaconda3/lib/python3.10/site-packages/seaborn/regression.py:413, in _RegressionPlotter.lineplot(self, ax, kws)
    411 """Draw the model."""
    412 # Fit the regression model
--> 413 grid, yhat, err_bands = self.fit_regression(ax)
    414 edges = grid[0], grid[-1]
    416 # Get set default aesthetics

File ~/anaconda3/lib/python3.10/site-packages/seaborn/regression.py:212, in _RegressionPlotter.fit_regression(self, ax, x_range, grid)
    210 elif self.lowess:
    211     ci = None
--> 212     grid, yhat = self.fit_lowess()
    213 elif self.robust:
    214     from statsmodels.robust.robust_linear_model import RLM

File ~/anaconda3/lib/python3.10/site-packages/seaborn/regression.py:292, in _RegressionPlotter.fit_lowess(self)
    290 def fit_lowess(self):
    291     """Fit a locally-weighted regression, which returns its own grid."""
--> 292     from statsmodels.nonparametric.smoothers_lowess import lowess
    293     grid, yhat = lowess(self.y, self.x).T
    294     return grid, yhat

ModuleNotFoundError: No module named 'statsmodels'
```


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) Report
> Merging [#3453](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (480504b) into [master](https://app.codecov.io/gh/mwaskom/seaborn/commit/ce02525ef40f8291165d705343c7cda87981484a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (ce02525) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)

```diff
@@           Coverage Diff           @@
##           master    #3453   +/-   ##
=======================================
  Coverage   98.32%   98.32%           
=======================================
  Files          75       75           
  Lines       24371    24384   +13     
=======================================
+ Hits        23963    23976   +13     
  Misses        408      408           
```


| [Files Changed](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) | Coverage Δ | |
|---|---|---|
| [seaborn/regression.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9yZWdyZXNzaW9uLnB5) | `98.51% <100.00%> (+0.03%)` | :arrow_up: |
| [tests/test\_regression.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3453?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9yZWdyZXNzaW9uLnB5) | `100.00% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/seaborn/regression.py b/seaborn/regression.py
index c6b81a17..5a16fc96 100644
--- a/seaborn/regression.py
+++ b/seaborn/regression.py
@@ -185,8 +185,18 @@ class _RegressionPlotter(_LinearPlotter):
 
         return vals, points, cis
 
+    def _check_statsmodels(self):
+        """Check whether statsmodels is installed if any boolean options require it."""
+        options = "logistic", "robust", "lowess"
+        err = "`{}=True` requires statsmodels, an optional dependency, to be installed."
+        for option in options:
+            if getattr(self, option) and not _has_statsmodels:
+                raise RuntimeError(err.format(option))
+
     def fit_regression(self, ax=None, x_range=None, grid=None):
         """Fit the regression model."""
+        self._check_statsmodels()
+
         # Create the grid for the regression
         if grid is None:
             if self.truncate:


Test patch:
diff --git a/tests/test_regression.py b/tests/test_regression.py
index ac020968..43675972 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -212,6 +212,12 @@ class TestRegressionPlotter:
         # Compare the vector of y_hat values
         npt.assert_array_almost_equal(yhat_poly, yhat_smod)
 
+    @pytest.mark.parametrize("option", ["logistic", "robust", "lowess"])
+    @pytest.mark.skipif(not _no_statsmodels, reason="statsmodels installed")
+    def test_statsmodels_missing_errors(self, long_df, option):
+        with pytest.raises(RuntimeError, match=rf"`{option}=True` requires"):
+            lm.regplot(long_df, x="x", y="y", **{option: True})
+
     def test_regress_logx(self):
 
         x = np.arange(1, 10)
@@ -663,6 +669,12 @@ class TestRegressionPlots:
         x, y = ax.lines[1].get_xydata().T
         npt.assert_array_equal(x, np.sort(self.df.x))
 
+    @pytest.mark.parametrize("option", ["robust", "lowess"])
+    @pytest.mark.skipif(not _no_statsmodels, reason="statsmodels installed")
+    def test_residplot_statsmodels_missing_errors(self, long_df, option):
+        with pytest.raises(RuntimeError, match=rf"`{option}=True` requires"):
+            lm.residplot(long_df, x="x", y="y", **{option: True})
+
     def test_three_point_colors(self):
 
         x, y = np.random.randn(2, 3)