@@ -136,9 +136,20 @@ def _validate_data(self) -> None:
136136 "equivalent and must not have rank\n "
137137 "less than the dimension of the smaller shape."
138138 )
139- if has_constant (f )[0 ]:
139+ rank = np .linalg .matrix_rank (f )
140+ constant = has_constant (f , rank )[0 ]
141+ if constant and rank < f .shape [1 ]:
142+ # has_constant is also True if the columns are collinear. Collinear
143+ # factors have a constant if a column is constant, or, for a matrix
144+ # with more rows than columns, if a constant is in the span of the
145+ # columns
146+ nobs = f .shape [0 ]
147+ aug_rank = np .linalg .matrix_rank (np .c_ [np .ones ((nobs , 1 )), f ])
148+ constant_column = np .any ((np .ptp (f , axis = 0 ) == 0 ) & ~ np .all (f == 0 , axis = 0 ))
149+ constant = bool (constant_column or (nobs > f .shape [1 ] and aug_rank == rank ))
150+ if constant :
140151 raise ValueError ("factors must not contain a constant or equivalent." )
141- if np . linalg . matrix_rank ( f ) < f .shape [1 ]:
152+ if rank < f .shape [1 ]:
142153 raise ValueError (
143154 "Model cannot be estimated. factors do not have full column rank."
144155 )
@@ -148,6 +159,18 @@ def _validate_data(self) -> None:
148159 "more returns to\n estimate the model parameters."
149160 )
150161
162+ def _check_debiased (self , debiased : bool ) -> None :
163+ """Debiasing needs more observations than regressors in the regressions"""
164+ nobs , nfactor = self .factors .shape
165+ if debiased and nobs <= nfactor + 1 :
166+ raise ValueError (
167+ "The covariance cannot be debiased when the number of observations "
168+ f"({ nobs } ) is not larger than the number of factors plus one "
169+ f"({ nfactor + 1 } ), the number of regressors of each time-series "
170+ "regression, since there are no residual degrees of freedom. Use "
171+ "debiased=False or more observations."
172+ )
173+
151174 @property
152175 def formula (self ) -> str | None :
153176 return self ._formula
@@ -326,6 +349,7 @@ def fit(
326349 ``kernel``, one of "bartlett", "parzen" or "qs" (quadratic spectral)
327350 and ``bandwidth`` (a positive integer).
328351 """
352+ self ._check_debiased (debiased )
329353 p = self .portfolios .ndarray
330354 f = self .factors .ndarray
331355 nportfolio = p .shape [1 ]
@@ -459,11 +483,33 @@ def __init__(
459483 self .portfolios .shape [1 ]
460484 )
461485 else :
462- self ._sigma = np . asarray (sigma )
463- vals , vecs = np .linalg .eigh (sigma )
486+ self ._sigma = self . _validate_sigma (sigma )
487+ vals , vecs = np .linalg .eigh (self . _sigma )
464488 self ._sigma_m12 = vecs @ np .diag (1.0 / np .sqrt (vals )) @ vecs .T
465489 self ._sigma_inv = np .linalg .inv (self ._sigma ).astype (float , copy = False )
466490
491+ def _validate_sigma (
492+ self , sigma : linearmodels .typing .data .ArrayLike
493+ ) -> linearmodels .typing .data .Float64Array :
494+ """Check that sigma is a symmetric positive definite matrix of the right size"""
495+ nportfolio = self .portfolios .shape [1 ]
496+ sigma_arr = np .asarray (sigma , dtype = float )
497+ if sigma_arr .shape != (nportfolio , nportfolio ):
498+ raise ValueError (
499+ f"sigma must be a square array with shape ({ nportfolio } , "
500+ f"{ nportfolio } ), where { nportfolio } is the number of test portfolios. "
501+ f"The shape of sigma is { sigma_arr .shape } ."
502+ )
503+ if not np .all (np .isfinite (sigma_arr )):
504+ raise ValueError ("sigma must not contain missing or infinite values." )
505+ scale = np .max (np .abs (sigma_arr ))
506+ if np .max (np .abs (sigma_arr - sigma_arr .T )) > 1e-8 * scale :
507+ raise ValueError ("sigma must be symmetric." )
508+ vals = np .linalg .eigvalsh ((sigma_arr + sigma_arr .T ) / 2 )
509+ if not vals [0 ] > nportfolio * np .finfo (float ).eps * vals [- 1 ]:
510+ raise ValueError ("sigma must be positive definite." )
511+ return sigma_arr
512+
467513 def __str__ (self ) -> str :
468514 out = super ().__str__ ()
469515 if np .any (self ._sigma != np .eye (self .portfolios .shape [1 ])):
@@ -509,8 +555,12 @@ class LinearFactorModel(_LinearFactorModelBase):
509555 Flag indicating whether the risk-free rate should be estimated
510556 from returns along other risk premia. If False, the returns are
511557 assumed to be excess returns using the correct risk-free rate.
512- sigma : array_like
513- Positive definite residual covariance (nportfolio by nportfolio)
558+ sigma : array_like, optional
559+ Positive definite residual covariance (nportfolio by nportfolio) that
560+ weights the test portfolios in the second stage. A ``ValueError`` is
561+ raised if it does not have this shape, contains missing or infinite
562+ values, or is not symmetric and positive definite. If not provided, the
563+ identity matrix is used.
514564
515565 Notes
516566 -----
@@ -591,8 +641,12 @@ def from_formula(
591641 Flag indicating whether the risk-free rate should be estimated
592642 from returns along other risk premia. If False, the returns are
593643 assumed to be excess returns using the correct risk-free rate.
594- sigma : array_like
595- Positive definite residual covariance (nportfolio by nportfolio)
644+ sigma : array_like, optional
645+ Positive definite residual covariance (nportfolio by nportfolio) that
646+ weights the test portfolios in the second stage. A ``ValueError`` is
647+ raised if it does not have this shape, contains missing or infinite
648+ values, or is not symmetric and positive definite. If not provided,
649+ the identity matrix is used.
596650
597651 Returns
598652 -------
@@ -659,6 +713,7 @@ def fit(
659713 """
660714 nobs , nf , nport , nrf , s1 , s2 , s3 = self ._boundaries ()
661715 excess_returns = not self ._risk_free
716+ self ._check_debiased (debiased )
662717 f = self .factors .ndarray
663718 p = self .portfolios .ndarray
664719 nport = p .shape [1 ]
@@ -1009,7 +1064,9 @@ def fit(
10091064 mod = LinearFactorModel (
10101065 self .portfolios , self .factors , risk_free = self ._risk_free
10111066 )
1012- res = mod .fit ()
1067+ # The starting values do not use the covariance, which cannot be debiased
1068+ # if there are no residual degrees of freedom in the first stage
1069+ res = mod .fit (debiased = False )
10131070 betas_1d = np .asarray (res .betas ).ravel ()
10141071 lam = np .asarray (res .risk_premia )
10151072 mu = self .factors .ndarray .mean (0 )
0 commit comments