Repository: alteryx/evalml (python)

Issue:
Add PCA Component
Add a [PCA](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html#sklearn.decomposition.PCA) component for dimensionality reduction. First of potentially multiple dimensionality reduction components.

Pull request:
Add PCA component
Closes #1262 by introducing a PCA component for dimensionality reduction.

As an initial sanity test, I ran AutoML on the MNIST dataset with and without the PCA component. The original dataset had 784 sparse features, after running PCA this was reduced to 154 features.

Training with current pipelines:
![Screen Shot 2020-10-07 at 10 10 32 AM](https://user-images.githubusercontent.com/60074782/95342377-5edac400-0885-11eb-872b-477fac619dab.png)

Training with the same pipelines but including the PCA component with default parameters:
![Screen Shot 2020-10-07 at 8 48 07 AM](https://user-images.githubusercontent.com/60074782/95332921-0eaa3480-087a-11eb-9b6d-0ebdd78a9572.png)

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 87e73d56..1bc89cef 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -10,6 +10,7 @@ Release Notes
         * Added parameter to ``OneHotEncoder`` to enable filtering for features to encode for :pr:`1249`
         * Added percent-better-than-baseline for all objectives to automl.results :pr:`1244`
         * Added ``HighVarianceCVDataCheck`` and replaced synonymous warning in ``AutoMLSearch`` :pr:`1254`
+        * Added `PCA Transformer` component for dimensionality reduction :pr:`1270`
     * Fixes
         * Fixed ML performance issue with ordered datasets: always shuffle data in automl's default CV splits :pr:`1265`
     * Changes
diff --git a/evalml/pipelines/components/__init__.py b/evalml/pipelines/components/__init__.py
index 1aa972f4..260ae70a 100644
--- a/evalml/pipelines/components/__init__.py
+++ b/evalml/pipelines/components/__init__.py
@@ -35,6 +35,7 @@ from .transformers import (
     SelectColumns,
     TextFeaturizer,
     LSA,
+    PCA
 )
 from .ensemble import (
     StackedEnsembleClassifier,
diff --git a/evalml/pipelines/components/transformers/__init__.py b/evalml/pipelines/components/transformers/__init__.py
index 9e1bd8b2..f0c378fa 100644
--- a/evalml/pipelines/components/transformers/__init__.py
+++ b/evalml/pipelines/components/transformers/__init__.py
@@ -4,4 +4,5 @@ from .feature_selection import FeatureSelector, RFClassifierSelectFromModel, RFR
 from .imputers import PerColumnImputer, SimpleImputer, Imputer
 from .scalers import StandardScaler
 from .column_selectors import DropColumns, SelectColumns
+from .dimensionality_reduction import PCA
 from .preprocessing import DateTimeFeaturizer, DropNullColumns, LSA, TextFeaturizer
diff --git a/evalml/pipelines/components/transformers/dimensionality_reduction/__init__.py b/evalml/pipelines/components/transformers/dimensionality_reduction/__init__.py
new file mode 100644
index 00000000..0d4591e9
--- /dev/null
+++ b/evalml/pipelines/components/transformers/dimensionality_reduction/__init__.py
@@ -0,0 +1,1 @@
+from .pca import PCA
diff --git a/evalml/pipelines/components/transformers/dimensionality_reduction/pca.py b/evalml/pipelines/components/transformers/dimensionality_reduction/pca.py
new file mode 100644
index 00000000..e4a8974e
--- /dev/null
+++ b/evalml/pipelines/components/transformers/dimensionality_reduction/pca.py
@@ -0,0 +1,60 @@
+import pandas as pd
+from sklearn.decomposition import PCA as SkPCA
+from skopt.space import Real
+
+from evalml.pipelines.components.transformers import Transformer
+from evalml.utils.gen_utils import is_all_numeric
+
+
+class PCA(Transformer):
+    """Reduces the number of features by using Principal Component Analysis"""
+    name = 'PCA Transformer'
+    hyperparameter_ranges = {
+        "variance": Real(0.25, 1)}
+
+    def __init__(self, variance=0.95, n_components=None, random_state=0, **kwargs):
+        """Initalizes an transformer that reduces the number of features using PCA."
+
+        Arguments:
+            variance (float): the percentage of the original data variance that should be preserved when reducing the
+                              number of features.
+            n_components (int): the number of features to maintain after computing SVD. Defaults to None, but will override
+                                variance variable if set.
+        """
+        parameters = {"variance": variance,
+                      "n_components": n_components}
+        parameters.update(kwargs)
+        if n_components:
+            pca = SkPCA(n_components=n_components, **kwargs)
+        else:
+            pca = SkPCA(n_components=variance, **kwargs)
+        super().__init__(parameters=parameters,
+                         component_obj=pca,
+                         random_state=random_state)
+
+    def fit(self, X, y=None):
+        if not isinstance(X, pd.DataFrame):
+            X = pd.DataFrame(X)
+        if not is_all_numeric(X):
+            raise ValueError("PCA input must be all numeric")
+
+        self._component_obj.fit(X)
+        return self
+
+    def transform(self, X, y=None):
+        if not isinstance(X, pd.DataFrame):
+            X = pd.DataFrame(X)
+        if not is_all_numeric(X):
+            raise ValueError("PCA input must be all numeric")
+
+        X_t = self._component_obj.transform(X)
+        return pd.DataFrame(X_t, index=X.index, columns=[f"component_{i}" for i in range(X_t.shape[1])])
+
+    def fit_transform(self, X, y=None):
+        if not isinstance(X, pd.DataFrame):
+            X = pd.DataFrame(X)
+        if not is_all_numeric(X):
+            raise ValueError("PCA input must be all numeric")
+
+        X_t = self._component_obj.fit_transform(X, y)
+        return pd.DataFrame(X_t, index=X.index, columns=[f"component_{i}" for i in range(X_t.shape[1])])
diff --git a/evalml/utils/gen_utils.py b/evalml/utils/gen_utils.py
index cb7b2859..ad6445cf 100644
--- a/evalml/utils/gen_utils.py
+++ b/evalml/utils/gen_utils.py
@@ -253,3 +253,20 @@ def safe_repr(value):
         if np.isinf(value):
             return f"float('{repr(value)}')"
     return repr(value)
+
+
+def is_all_numeric(df):
+    """Checks if the given DataFrame contains only numeric values
+
+    Arguments:
+        df (DataFrame): The DataFrame to check datatypes of
+
+    Returns:
+        True if all the DataFrame columns are numeric and are not missing any values, False otherwise
+    """
+    if df.isnull().any().any():
+        return False
+    for dtype in df.dtypes:
+        if dtype not in numeric_and_boolean_dtypes:
+            return False
+    return True


Test patch:
diff --git a/evalml/tests/component_tests/test_components.py b/evalml/tests/component_tests/test_components.py
index 0e8552d5..20269e0d 100644
--- a/evalml/tests/component_tests/test_components.py
+++ b/evalml/tests/component_tests/test_components.py
@@ -18,6 +18,7 @@ from evalml.exceptions import (
 from evalml.model_family import ModelFamily
 from evalml.pipelines.components import (
     LSA,
+    PCA,
     BaselineClassifier,
     BaselineRegressor,
     CatBoostClassifier,
@@ -142,6 +143,7 @@ def test_describe_component():
     datetime = DateTimeFeaturizer()
     text_featurizer = TextFeaturizer()
     lsa = LSA()
+    pca = PCA()
     assert enc.describe(return_dict=True) == {'name': 'One Hot Encoder', 'parameters': {'top_n': 10,
                                                                                         'features_to_encode': None,
                                                                                         'categories': None,
@@ -162,6 +164,7 @@ def test_describe_component():
     assert datetime.describe(return_dict=True) == {'name': 'DateTime Featurization Component', 'parameters': {'features_to_extract': ['year', 'month', 'day_of_week', 'hour']}}
     assert text_featurizer.describe(return_dict=True) == {'name': 'Text Featurization Component', 'parameters': {'text_columns': None}}
     assert lsa.describe(return_dict=True) == {'name': 'LSA Transformer', 'parameters': {'text_columns': None}}
+    assert pca.describe(return_dict=True) == {'name': 'PCA Transformer', 'parameters': {'n_components': None, 'variance': 0.95}}
 
     # testing estimators
     base_classifier = BaselineClassifier()
@@ -521,6 +524,10 @@ def test_transformer_transform_output_type(X_y_binary):
             if isinstance(component, SelectColumns):
                 assert transform_output.shape == (X.shape[0], 0)
                 assert isinstance(transform_output.columns, pd.Index)
+            elif isinstance(component, PCA):
+                assert transform_output.shape[0] == X.shape[0]
+                assert transform_output.shape[1] <= X.shape[1]
+                assert isinstance(transform_output.columns, pd.Index)
             else:
                 assert transform_output.shape == X.shape
                 assert (transform_output.columns == X_cols_expected).all()
@@ -531,6 +538,10 @@ def test_transformer_transform_output_type(X_y_binary):
             if isinstance(component, SelectColumns):
                 assert transform_output.shape == (X.shape[0], 0)
                 assert isinstance(transform_output.columns, pd.Index)
+            elif isinstance(component, PCA):
+                assert transform_output.shape[0] == X.shape[0]
+                assert transform_output.shape[1] <= X.shape[1]
+                assert isinstance(transform_output.columns, pd.Index)
             else:
                 assert transform_output.shape == X.shape
                 assert (transform_output.columns == X_cols_expected).all()
diff --git a/evalml/tests/component_tests/test_pca.py b/evalml/tests/component_tests/test_pca.py
new file mode 100644
index 00000000..d58a158c
--- /dev/null
+++ b/evalml/tests/component_tests/test_pca.py
@@ -0,0 +1,115 @@
+import numpy as np
+import pandas as pd
+import pytest
+from pandas.testing import assert_frame_equal
+
+from evalml.pipelines.components import PCA
+
+
+def test_pca_numeric():
+    X = pd.DataFrame([[3, 0, 1, 6],
+                      [1, 2, 1, 6],
+                      [10, 2, 1, 6],
+                      [10, 2, 2, 5],
+                      [6, 2, 2, 5]])
+    pca = PCA()
+    expected_X_t = pd.DataFrame([[3.176246, 1.282616],
+                                 [4.969987, -0.702976],
+                                 [-3.954182, 0.429071],
+                                 [-4.079174, -0.252790],
+                                 [-0.112877, -0.755922]],
+                                columns=[f"component_{i}" for i in range(2)])
+    X_t = pd.DataFrame(pca.fit_transform(X))
+    assert_frame_equal(X_t, expected_X_t)
+
+
+def test_pca_array():
+    X = [[3, 0, 1, 6],
+         [1, 2, 1, 6],
+         [10, 2, 1, 6],
+         [10, 2, 2, 5],
+         [6, 2, 2, 5]]
+    pca = PCA()
+    expected_X_t = pd.DataFrame([[3.176246, 1.282616],
+                                 [4.969987, -0.702976],
+                                 [-3.954182, 0.429071],
+                                 [-4.079174, -0.252790],
+                                 [-0.112877, -0.755922]],
+                                columns=[f"component_{i}" for i in range(2)])
+    pca.fit(X)
+    X_t = pd.DataFrame(pca.transform(X))
+    assert_frame_equal(X_t, expected_X_t)
+
+
+def test_pca_invalid():
+    X = pd.DataFrame([[3, 0, 1, 6],
+                      [1, None, 1, 6],
+                      [10, 2, 1, 6],
+                      [10, 2, 2, np.nan],
+                      [None, 2, 2, 5]])
+    pca = PCA()
+    with pytest.raises(ValueError, match="must be all numeric"):
+        pca.fit(X)
+
+    X = pd.DataFrame([[3, 0, 1, 6],
+                      ['a', 'b', 'a', 'b'],
+                      [10, 2, 1, 6],
+                      [10, 2, 2, 23],
+                      [0, 2, 2, 5]])
+    pca = PCA()
+    with pytest.raises(ValueError, match="must be all numeric"):
+        pca.fit_transform(X)
+
+    X_ok = pd.DataFrame([[3, 0, 1, 6],
+                         [1, 2, 1, 6],
+                         [10, 2, 1, 6],
+                         [10, 2, 2, 5],
+                         [6, 2, 2, 5]])
+    pca = PCA()
+    pca.fit(X_ok)
+    with pytest.raises(ValueError, match="must be all numeric"):
+        pca.transform(X)
+
+
+def test_variance():
+    X = pd.DataFrame([[3, 0, 1, 6, 5, 10],
+                      [1, 2, 1, 3, 11, 4],
+                      [10, 2, 1, 12, 5, 6],
+                      [10, 6, 4, 4, 0, 1],
+                      [6, 8, 9, 3, 1, 5]])
+    pca = PCA(variance=0.97)
+    expected_X_t = pd.DataFrame([[-5.581732, 0.469307, 3.985657, 1.760273],
+                                 [-6.961064, -5.026062, -3.170519, -0.624576],
+                                 [-1.352624, 7.778657, -0.778879, -1.554429],
+                                 [7.067179, 0.645894, -2.633617, 2.159135],
+                                 [6.828241, -3.867796, 2.597358, -1.740404]],
+                                columns=[f"component_{i}" for i in range(4)])
+    X_t_90 = pd.DataFrame(pca.fit_transform(X))
+    assert_frame_equal(X_t_90, expected_X_t)
+
+    pca = PCA(variance=0.75)
+    X_t_75 = pd.DataFrame(pca.fit_transform(X))
+    assert X_t_75.shape[1] < X_t_90.shape[1]
+
+    pca = PCA(variance=0.50)
+    X_t_50 = pd.DataFrame(pca.fit_transform(X))
+    assert X_t_50.shape[1] < X_t_75.shape[1]
+
+
+def test_n_components():
+    X = pd.DataFrame([[3, 0, 1, 6, 5, 10],
+                      [1, 2, 1, 3, 11, 4],
+                      [10, 2, 1, 12, 5, 6],
+                      [10, 6, 4, 4, 0, 1],
+                      [6, 8, 9, 3, 1, 5]])
+    pca = PCA(n_components=5)
+    X_t = pd.DataFrame(pca.fit_transform(X))
+    assert X_t.shape[1] == 5
+
+    pca = PCA(n_components=3)
+    X_t = pd.DataFrame(pca.fit_transform(X))
+    assert X_t.shape[1] == 3
+
+    pca = PCA(n_components=1)
+    X_t = pd.DataFrame(pca.fit_transform(X))
+    assert X_t.shape[1] == 1
diff --git a/evalml/tests/component_tests/test_utils.py b/evalml/tests/component_tests/test_utils.py
index acd8dfe2..1d4c6748 100644
--- a/evalml/tests/component_tests/test_utils.py
+++ b/evalml/tests/component_tests/test_utils.py
@@ -18,9 +18,9 @@ from evalml.problem_types import ProblemTypes
 
 def test_all_components(has_minimal_dependencies):
     if has_minimal_dependencies:
-        assert len(all_components()) == 27
+        assert len(all_components()) == 28
     else:
-        assert len(all_components()) == 32
+        assert len(all_components()) == 33
 
 
 def test_handle_component_class_names():