Repository: statsmodels/statsmodels

Issue:
missing values in PCA
`statsmodel: 0.11.0`

consider the following example: 

```python
import numpy as np
from statsmodels.multivariate.pca import PCA

x = np.random.randn(100)[:, None]
x = x + np.random.randn(100, 100)
x = np.insert(x, 1, np.nan, axis=1)

pc = PCA(x)
```
Returns
`LinAlgError: SVD did not converge`

I would say we should set a default value for `missing`. (or at least change the error msg for users). Meanwhile, by default, we have `standardize=True`, which will also need missing values handling. So right now it seems like the `PCA` does something for the missing value first and then doesn't do anything for the actual pca part? 

* change the error message
* or set a default value for missing values
* add how missing values are handle for  `standardize=True` in the doc.

https://www.statsmodels.org/stable/generated/statsmodels.multivariate.pca.PCA.html


Discussion:
See the option "fill-em".

There are also options to drop rows or drop columns. 
Will raise on missing if correct options are not set. 

Resolving patch:
diff --git a/statsmodels/multivariate/pca.py b/statsmodels/multivariate/pca.py
--- a/statsmodels/multivariate/pca.py
+++ b/statsmodels/multivariate/pca.py
@@ -9,6 +9,12 @@
 
 from statsmodels.tools.sm_exceptions import (ValueWarning,
                                              EstimationWarning)
+from statsmodels.tools.validation import (string_like,
+                                          array_like,
+                                          bool_like,
+                                          float_like,
+                                          int_like,
+                                          )
 
 
 def _norm(x):
@@ -49,19 +55,22 @@ class PCA(object):
         Series weights to use after transforming data according to standardize
         or demean when computing the principal components.
     method : str, optional
-        Sets the linear algebra routine used to compute eigenvectors
-        'svd' uses a singular value decomposition (default).
-        'eig' uses an eigenvalue decomposition of a quadratic form
-        'nipals' uses the NIPALS algorithm and can be faster than SVD when
-        ncomp is small and nvars is large. See notes about additional changes
-        when using NIPALS.
-    missing : str
+        Sets the linear algebra routine used to compute eigenvectors:
+
+        * 'svd' uses a singular value decomposition (default).
+        * 'eig' uses an eigenvalue decomposition of a quadratic form
+        * 'nipals' uses the NIPALS algorithm and can be faster than SVD when
+          ncomp is small and nvars is large. See notes about additional changes
+          when using NIPALS.
+    missing : {str, None}
         Method for missing data.  Choices are:
-        'drop-row' - drop rows with missing values.
-        'drop-col' - drop columns with missing values.
-        'drop-min' - drop either rows or columns, choosing by data retention.
-        'fill-em' - use EM algorithm to fill missing value.  ncomp should be
-        set to the number of factors required.
+
+        * 'drop-row' - drop rows with missing values.
+        * 'drop-col' - drop columns with missing values.
+        * 'drop-min' - drop either rows or columns, choosing by data retention.
+        * 'fill-em' - use EM algorithm to fill missing value.  ncomp should be
+          set to the number of factors required.
+        * `None` raises if data contains NaN values.
     tol : float, optional
         Tolerance to use when checking for convergence when using NIPALS.
     max_iter : int, optional
@@ -192,22 +201,23 @@ def __init__(self, data, ncomp=None, standardize=True, demean=True,
             self._index = data.index
             self._columns = data.columns
 
-        self.data = np.asarray(data)
+        self.data = array_like(data, "data", ndim=2)
         # Store inputs
-        self._gls = gls
-        self._normalize = normalize
-        self._tol = tol
+        self._gls = bool_like(gls, "gls")
+        self._normalize = bool_like(normalize, "normalize")
+        self._tol = float_like(tol, "tol")
         if not 0 < self._tol < 1:
             raise ValueError('tol must be strictly between 0 and 1')
-        self._max_iter = max_iter
-        self._max_em_iter = max_em_iter
-        self._tol_em = tol_em
+        self._max_iter = int_like(max_iter, "int_like")
+        self._max_em_iter = int_like(max_em_iter, "max_em_iter")
+        self._tol_em = float_like(tol_em, "tol_em")
 
         # Prepare data
-        self._standardize = standardize
-        self._demean = demean
+        self._standardize = bool_like(standardize, "standardize")
+        self._demean = bool_like(demean, "demean")
 
         self._nobs, self._nvar = self.data.shape
+        weights = array_like(weights, "weights", maxdim=1, optional=True)
         if weights is None:
             weights = np.ones(self._nvar)
         else:
@@ -237,19 +247,19 @@ def __init__(self, data, ncomp=None, standardize=True, demean=True,
         self.rows = np.arange(self._nobs)
         self.cols = np.arange(self._nvar)
         # Handle missing
-        self._missing = missing
+        self._missing = string_like(missing, "missing", optional=True)
         self._adjusted_data = self.data
-        if missing is not None:
-            self._adjust_missing()
-            # Update size
-            self._nobs, self._nvar = self._adjusted_data.shape
-            if self._ncomp == np.min(self.data.shape):
-                self._ncomp = np.min(self._adjusted_data.shape)
-            elif self._ncomp > np.min(self._adjusted_data.shape):
-                raise ValueError('When adjusting for missing values, user '
-                                 'provided ncomp must be no larger than the '
-                                 'smallest dimension of the '
-                                 'missing-value-adjusted data size.')
+        self._adjust_missing()
+
+        # Update size
+        self._nobs, self._nvar = self._adjusted_data.shape
+        if self._ncomp == np.min(self.data.shape):
+            self._ncomp = np.min(self._adjusted_data.shape)
+        elif self._ncomp > np.min(self._adjusted_data.shape):
+            raise ValueError('When adjusting for missing values, user '
+                             'provided ncomp must be no larger than the '
+                             'smallest dimension of the '
+                             'missing-value-adjusted data size.')
 
         # Attributes and internal values
         self._tss = 0.0
@@ -318,6 +328,11 @@ def keep_row(x):
                 self.cols = np.where(drop_col_index)[0]
         elif self._missing == 'fill-em':
             self._adjusted_data = self._fill_missing_em()
+        elif self._missing is None:
+            if not np.isfinite(self._adjusted_data).all():
+                raise ValueError("""\
+data contains non-finite values (inf, NaN). You should drop these values or
+use one of the methods for adjusting data for missing-values.""")
         else:
             raise ValueError('missing method is not known.')
 
@@ -334,7 +349,8 @@ def _compute_gls_weights(self):
         """
         Computes GLS weights based on percentage of data fit
         """
-        errors = self.transformed_data - np.asarray(self.projection)
+        projection = np.asarray(self.project(transform=False))
+        errors = self.transformed_data - projection
         if self._ncomp == self._nvar:
             raise ValueError('gls can only be used when ncomp < nvar '
                              'so that residuals have non-zero variance')
@@ -347,10 +363,9 @@ def _compute_gls_weights(self):
             eff_series = int(np.round(eff_series_perc * nvar))
             import warnings
 
-            warn = 'Many series are being down weighted by GLS. Of the ' \
-                   '{original} series, the GLS estimates are based on only ' \
-                   '{effective} (effective) ' \
-                   'series.'.format(original=nvar, effective=eff_series)
+            warn = f"""\
+Many series are being down weighted by GLS. Of the {nvar} series, the GLS
+estimates are based on only {eff_series} (effective) series."""
             warnings.warn(warn, EstimationWarning)
 
         self.weights = weights
diff --git a/statsmodels/tools/validation/validation.py b/statsmodels/tools/validation/validation.py
--- a/statsmodels/tools/validation/validation.py
+++ b/statsmodels/tools/validation/validation.py
@@ -33,7 +33,7 @@ def _right_squeeze(arr, stop_dim=0):
 
 
 def array_like(obj, name, dtype=np.double, ndim=1, maxdim=None,
-               shape=None, order='C', contiguous=False, optional=False):
+               shape=None, order=None, contiguous=False, optional=False):
     """
     Convert array-like to a ndarray and check conditions
 
@@ -57,7 +57,7 @@ def array_like(obj, name, dtype=np.double, ndim=1, maxdim=None,
     shape : {tuple[int], None}
         Required shape obj.  If None, no check is performed. Partially
         restricted shapes can be checked using None. See examples.
-    order : {'C', 'F'}
+    order : {'C', 'F', None}
         Order of the array
     contiguous : bool
         Ensure that the array's data is contiguous with order ``order``


Test patch:
diff --git a/statsmodels/genmod/tests/test_qif.py b/statsmodels/genmod/tests/test_qif.py
--- a/statsmodels/genmod/tests/test_qif.py
+++ b/statsmodels/genmod/tests/test_qif.py
@@ -7,6 +7,7 @@
 from statsmodels.tools.numdiff import approx_fprime
 from statsmodels.genmod import families
 
+
 @pytest.mark.parametrize("fam", [families.Gaussian(), families.Poisson(),
                          families.Binomial()])
 @pytest.mark.parametrize("cov_struct", [QIFIndependence(), QIFExchangeable(),
@@ -84,6 +85,7 @@ def test_qif_fit(fam, cov_struct):
     # Smoke test
     _ = rslt.summary()
 
+
 @pytest.mark.parametrize("cov_struct", [QIFIndependence(), QIFExchangeable(),
                          QIFAutoregressive()])
 def test_formula(cov_struct):
diff --git a/statsmodels/multivariate/tests/test_pca.py b/statsmodels/multivariate/tests/test_pca.py
--- a/statsmodels/multivariate/tests/test_pca.py
+++ b/statsmodels/multivariate/tests/test_pca.py
@@ -1,4 +1,3 @@
-
 from statsmodels.compat.platform import PLATFORM_WIN32
 
 import warnings
@@ -8,9 +7,10 @@
 import pytest
 from numpy.testing import assert_allclose, assert_equal, assert_raises
 
-from statsmodels.multivariate.pca import PCA
+from statsmodels.multivariate.pca import PCA, pca
 from statsmodels.multivariate.tests.results.datamlw import (data, princomp1,
                                                             princomp2)
+from statsmodels.tools.sm_exceptions import EstimationWarning
 
 DECIMAL_5 = .00001
 
@@ -60,6 +60,8 @@ def test_smoke_plot_and_repr(self, close_figures):
         pc.__repr__()
         pc = PCA(self.x, standardize=False, demean=False)
         pc.__repr__()
+        pc = PCA(self.x, ncomp=2, gls=True)
+        assert "GLS" in pc.__repr__()
         # Check data for no changes
         assert_equal(self.x, pc.data)
 
@@ -179,7 +181,7 @@ def test_warnings_and_errors(self):
         assert_raises(ValueError, PCA, self.x, method='unknown')
         assert_raises(ValueError, PCA, self.x, missing='unknown')
         assert_raises(ValueError, PCA, self.x, tol=2.0)
-        assert_raises(ValueError, PCA, np.nan * np.ones((200,100)), tol=2.0)
+        assert_raises(ValueError, PCA, np.nan * np.ones((200, 100)), tol=2.0)
 
     @pytest.mark.matplotlib
     def test_pandas(self, close_figures):
@@ -405,3 +407,31 @@ def test_missing_dataframe(self):
         pc_df = PCA(x_df, missing='drop-min')
         assert_allclose(pc.coeff, pc_df.coeff)
         assert_allclose(pc.factors, pc_df.factors)
+
+    def test_equivalence(self):
+        x = self.x.copy()
+        assert_allclose(PCA(x).factors, pca(x)[0])
+
+
+def test_missing():
+    data = np.empty((200, 50))
+    data[0, 0] = np.nan
+    with pytest.raises(ValueError, match="data contains non-finite values"):
+        PCA(data)
+
+
+def test_too_many_missing(reset_randomstate):
+    data = np.random.standard_normal((200, 50))
+    data[0, :-3] = np.nan
+    with pytest.raises(ValueError):
+        PCA(data, ncomp=5, missing="drop-col")
+    p = PCA(data, missing="drop-min")
+    assert max(p.factors.shape) == max(data.shape) - 1
+
+
+def test_gls_warning(reset_randomstate):
+    data = np.random.standard_normal((400, 200))
+    data[:, 1:] = data[:, :1] + .01 * data[:, 1:]
+    with pytest.warns(EstimationWarning, match="Many series are being down weighted"):
+        factors = PCA(data, ncomp=2, gls=True).factors
+    assert factors.shape == (data.shape[0], 2)
diff --git a/statsmodels/tsa/arima/estimators/tests/test_gls.py b/statsmodels/tsa/arima/estimators/tests/test_gls.py
--- a/statsmodels/tsa/arima/estimators/tests/test_gls.py
+++ b/statsmodels/tsa/arima/estimators/tests/test_gls.py
@@ -7,6 +7,7 @@
 from statsmodels.tsa.arima.datasets.brockwell_davis_2002 import lake, oshorts
 from statsmodels.tsa.arima.estimators.gls import gls
 
+
 @pytest.mark.low_precision('Test against Example 6.6.1 in Brockwell and Davis'
                            ' (2016)')
 def test_brockwell_davis_example_661():
diff --git a/statsmodels/tsa/vector_ar/hypothesis_test_results.py b/statsmodels/tsa/vector_ar/hypothesis_test_results.py
--- a/statsmodels/tsa/vector_ar/hypothesis_test_results.py
+++ b/statsmodels/tsa/vector_ar/hypothesis_test_results.py
@@ -114,7 +114,7 @@ def __init__(self, causing, caused, test_statistic, crit_value, pvalue, df,
         if len(self.causing) == 1:
             h0 += "{} does not ".format(self.causing[0])
         else:
-            h0 += "%s do not ".format(self.causing)
+            h0 += "{} do not ".format(self.causing)
         h0 += "Granger-" if self.test == "granger" else "instantaneously "
         h0 += "cause "
         if len(self.caused) == 1: