Repository: DistrictDataLabs/yellowbrick

Issue:
Add Q-Q plot to the yellowbrick.regressor.residuals class
You already have a histogram feature for the `ResidulaPlot` method.
It will be immensely helpful to add a standard normality check method to the parent `yellowbrick.regressor.residuals` class like Q-Q plot.

Your functional interface is gearing up to be similar to statistical languages like **R** where you can throw the fitted model inside a function to generate more insight - mostly a visualization or a statistical score.

[Q-Q plot](https://en.wikipedia.org/wiki/Q%E2%80%93Q_plot) ability will be one of such basic statistical insights that can add value.



Discussion:
@tirthajyoti Thanks for opening this issue.  This is an excellent suggestion.  If this is something you would like to work on then you could signup as a core-contributor during our fall semester and begin working on it then.   https://forms.gle/NT3inRQhaV278kf96
I will see what I can do. Is it OK if I use a [`SciPy` method](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.probplot.html) for this to keep the code compact and fast?
@tirthajyoti We definitely use scipy methods... for instance https://github.com/DistrictDataLabs/yellowbrick/blob/76b2f8854a44dde534609b9545d8215d43c6555b/yellowbrick/features/rankd.py#L21

I hope to see you this Fall


Resolving patch:
diff --git a/yellowbrick/regressor/residuals.py b/yellowbrick/regressor/residuals.py
--- a/yellowbrick/regressor/residuals.py
+++ b/yellowbrick/regressor/residuals.py
@@ -21,6 +21,8 @@
 
 import matplotlib.pyplot as plt
 
+from scipy.stats import probplot
+
 try:
     # Only available in Matplotlib >= 2.0.2
     from mpl_toolkits.axes_grid1 import make_axes_locatable
@@ -434,6 +436,12 @@ class ResidualsPlot(RegressionScoreVisualizer):
         If set to 'density', the probability density function will be plotted.
         If set to True or 'frequency' then the frequency will be plotted.
 
+    qqplot : {True, False}, default: False
+        Draw a Q-Q plot on the right side of the figure, comparing the quantiles
+        of the residuals against quantiles of a standard normal distribution.
+        Q-Q plot and histogram of residuals can not be plotted simultaneously,
+        either `hist` or `qqplot` has to be set to False.
+
     train_color : color, default: 'b'
         Residuals for training data are ploted with this color but also
         given an opacity of 0.5 to ensure that the test data residuals
@@ -502,6 +510,7 @@ def __init__(
         model,
         ax=None,
         hist=True,
+        qqplot=False,
         train_color="b",
         test_color="g",
         line_color=LINE_COLOR,
@@ -531,9 +540,25 @@ def __init__(
                 "False, 'density', or 'frequency'".format(hist)
             )
 
+        self.qqplot = qqplot
+        if self.qqplot not in {True, False}:
+            raise YellowbrickValueError(
+                "'{}' is an invalid argument for qqplot, use True, "
+                " or False".format(hist)
+            )
+
+        if self.hist in {True, "density", "frequency"} and self.qqplot in {True}:
+            raise YellowbrickValueError(
+                "Set either hist or qqplot to False, can not plot "
+                "both of them simultaneously."
+            )
+
         if self.hist in {True, "density", "frequency"}:
             self.hax  # If hist is True, test the version availability
 
+        if self.qqplot in {True}:
+            self.qqax  # If qqplot is True, test the version availability
+
         # Store labels and colors for the legend ordered by call
         self._labels, self._colors = [], []
 
@@ -560,6 +585,26 @@ def hax(self):
 
         return hax
 
+    @memoized
+    def qqax(self):
+        """
+        Returns the Q-Q plot axes, creating it only on demand.
+        """
+        if make_axes_locatable is None:
+            raise YellowbrickValueError(
+                (
+                    "residuals histogram requires matplotlib 2.0.2 or greater "
+                    "please upgrade matplotlib or set qqplot=False on the visualizer"
+                )
+            )
+
+        divider = make_axes_locatable(self.ax)
+
+        qqax = divider.append_axes("right", size=2, pad=0.25, sharey=self.ax)
+        qqax.yaxis.tick_right()
+
+        return qqax
+
     def fit(self, X, y, **kwargs):
         """
         Parameters
@@ -670,6 +715,12 @@ def draw(self, y_pred, residuals, train=False, **kwargs):
                 residuals, bins=50, orientation="horizontal", density=True, color=color
             )
 
+        # Add residuals histogram
+        if self.qqplot in {True}:
+            osm, osr = probplot(residuals, dist='norm', fit=False)
+
+            self.qqax.scatter(osm, osr, c=color, alpha=alpha, label=label)
+
         # Ensure the current axes is always the main residuals axes
         plt.sca(self.ax)
         return self.ax
@@ -705,6 +756,12 @@ def finalize(self, **kwargs):
             self.hax.axhline(y=0, c=self.colors["line"])
             self.hax.set_xlabel("Distribution")
 
+        # Finalize the histogram axes
+        if self.qqplot:
+            self.qqax.set_title("Q-Q plot")
+            self.qqax.set_xlabel("Theoretical quantiles")
+            self.qqax.set_ylabel("Observed quantiles")
+
 
 ##########################################################################
 ## Quick Method
@@ -719,6 +776,7 @@ def residuals_plot(
     y_test=None,
     ax=None,
     hist=True,
+    qqplot=False,
     train_color="b",
     test_color="g",
     line_color=LINE_COLOR,
@@ -772,6 +830,12 @@ def residuals_plot(
         If set to 'density', the probability density function will be plotted.
         If set to True or 'frequency' then the frequency will be plotted.
 
+    qqplot : {True, False}, default: False
+        Draw a Q-Q plot on the right side of the figure, comparing the quantiles
+        of the residuals against quantiles of a standard normal distribution.
+        Q-Q plot and histogram of residuals can not be plotted simultaneously,
+        either `hist` or `qqplot` has to be set to False.
+
     train_color : color, default: 'b'
         Residuals for training data are ploted with this color but also
         given an opacity of 0.5 to ensure that the test data residuals
@@ -822,6 +886,7 @@ def residuals_plot(
         model=model,
         ax=ax,
         hist=hist,
+        qqplot=qqplot,
         train_color=train_color,
         test_color=test_color,
         line_color=line_color,


Test patch:
diff --git a/tests/baseline_images/test_regressor/test_residuals/test_residuals_plot_QQ_plot.png b/tests/baseline_images/test_regressor/test_residuals/test_residuals_plot_QQ_plot.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_regressor/test_residuals/test_residuals_plot_QQ_plot.png differ
diff --git a/tests/test_regressor/test_residuals.py b/tests/test_regressor/test_residuals.py
--- a/tests/test_regressor/test_residuals.py
+++ b/tests/test_regressor/test_residuals.py
@@ -280,6 +280,32 @@ def test_residuals_plot(self):
 
         self.assert_images_similar(visualizer)
 
+    @pytest.mark.xfail(
+        IS_WINDOWS_OR_CONDA,
+        reason="font rendering different in OS and/or Python; see #892",
+    )
+    def test_residuals_plot_QQ_plot(self):
+        """
+        Image similarity of residuals and Q-Q plot on random data with OLS
+        """
+        _, ax = plt.subplots()
+
+        visualizer = ResidualsPlot(LinearRegression(), hist=False,
+                                                        qqplot=True, ax=ax)
+
+        visualizer.fit(self.data.X.train, self.data.y.train)
+        visualizer.score(self.data.X.test, self.data.y.test)
+
+        self.assert_images_similar(visualizer)
+
+    def test_either_hist_or_QQ_plot(self):
+        """
+        Setting both hist=True and qqplot=True raises exception.
+        """
+        with pytest.raises(YellowbrickValueError,
+                match="Set either hist or qqplot to False"):
+            ResidualsPlot(LinearRegression(), hist=True, qqplot=True)
+
     @pytest.mark.xfail(
         sys.platform == "win32", reason="images not close on windows (RMSE=32)"
     )