Skip to content

BUG: Raise a ValueError for a debiased factor model without residual … - #802

Merged
bashtage merged 5 commits into
mainfrom
lm71-fix-system-asset-pricing-small
Oct 13, 2026
Merged

bashtage merged 5 commits into
mainfrom
lm71-fix-system-asset-pricing-small

Conversation

@bashtage

Copy link
Copy Markdown
Owner

…degrees of freedom

With as many observations as regressors in the time-series regressions, nobs
equal to nfactor + 1, the regressions fit perfectly and have no residual degrees
of freedom. The debiased covariance, which is the default, divides by
nobs - (nfactor + 1) = 0, and TradedFactorModel.fit and LinearFactorModel.fit
raised a bare ZeroDivisionError ("division by zero").

They now raise a ValueError that says why and what to do:

The covariance cannot be debiased when the number of observations (3) is not
larger than the number of factors plus one (3), the number of regressors of
each time-series regression, since there are no residual degrees of freedom.
Use debiased=False or more observations.

Fitting with debiased=False, or with at least one more observation, is not
changed. The model already requires nobs >= nfactor + 1.

LinearFactorModelGMM.fit estimates the starting values with LinearFactorModel,
and used its default, debiased covariance, which the starting values do not need.
It raised the same ZeroDivisionError for a saturated model even with
debiased=False, and would have raised this ValueError, which refers to an option
that the user did not choose. The starting values are now estimated with
debiased=False. Such a model has too few observations for the GMM weight matrix,
and the GMM estimator now fails with the error of the singular weight matrix
instead of a ZeroDivisionError.

test_saturated_model.py checks the error, the models that work, and that the
starting values of the GMM are not debiased.

…degrees of freedom

With as many observations as regressors in the time-series regressions, nobs
equal to nfactor + 1, the regressions fit perfectly and have no residual degrees
of freedom. The debiased covariance, which is the default, divides by
nobs - (nfactor + 1) = 0, and TradedFactorModel.fit and LinearFactorModel.fit
raised a bare ZeroDivisionError ("division by zero").

They now raise a ValueError that says why and what to do:

    The covariance cannot be debiased when the number of observations (3) is not
    larger than the number of factors plus one (3), the number of regressors of
    each time-series regression, since there are no residual degrees of freedom.
    Use debiased=False or more observations.

Fitting with debiased=False, or with at least one more observation, is not
changed. The model already requires nobs >= nfactor + 1.

LinearFactorModelGMM.fit estimates the starting values with LinearFactorModel,
and used its default, debiased covariance, which the starting values do not need.
It raised the same ZeroDivisionError for a saturated model even with
debiased=False, and would have raised this ValueError, which refers to an option
that the user did not choose. The starting values are now estimated with
debiased=False. Such a model has too few observations for the GMM weight matrix,
and the GMM estimator now fails with the error of the singular weight matrix
instead of a ZeroDivisionError.

test_saturated_model.py checks the error, the models that work, and that the
starting values of the GMM are not debiased.
The factor models check the factors for a constant and then for full column rank:

    if has_constant(f)[0]:
        raise ValueError("factors must not contain a constant or equivalent.")
    if np.linalg.matrix_rank(f) < f.shape[1]:
        raise ValueError("Model cannot be estimated. factors do not have full column rank.")

has_constant also returns True when the matrix does not have full column rank, so
the first check caught collinear factors and the message for them could only be
reached by a matrix with more columns than rows. Factors that were a duplicated
column, or a linear combination of the others, raised "factors must not contain a
constant or equivalent."

The rank is now computed first. If has_constant is True and the factors do not
have full column rank, they are reported as containing a constant only if a
column is constant, or, for a matrix with more rows than columns, if a constant is
in the span of the columns, the cases that has_constant finds besides the lack of
rank. Otherwise they raise "Model cannot be estimated. factors do not have full
column rank." The messages of factors with a constant, including two constant
columns, a constant that is not one and a constant in the span of the factors, do
not change, and neither do those of factors with more columns than rows. The
has_constant function is not changed.

test_factor_collinearity.py checks the messages of duplicated and combined
columns, of the combinations of a constant and collinear columns, and of valid
factors, for the three models, with NumPy and pandas. The test of test_model.py
that expected the message of a constant for a duplicated factor expects the
message of the rank.
sigma, the covariance that weights the test portfolios in the second stage, was
not validated. The constructor accepted an array of the wrong shape and the
error appeared later, from the first use of sigma, or not at all:

- an array that is not nportfolio by nportfolio failed in fit with
  "matmul: Input operand 1 has a mismatch in its core dimension 0";
- a matrix that is not positive definite failed in fit with
  LinAlgError("SVD did not converge in Linear Least Squares"), or gave a
  NaN with a RuntimeWarning from the square root of a negative eigenvalue;
- a matrix that is not symmetric was used through its lower triangle by the
  eigendecomposition and through its full self by the inverse.

The constructor, and so from_formula, now raises a ValueError that names the
problem if sigma
- does not have shape (nportfolio, nportfolio): "sigma must be a square array
  with shape (6, 6), where 6 is the number of test portfolios. The shape of
  sigma is (5, 5).";
- contains missing or infinite values;
- is not symmetric, up to a relative difference of 1e-8 of the largest element,
  so that a product of matrices that is symmetric up to rounding is accepted;
- is not positive definite, which includes a covariance that is singular, such
  as the covariance of 6 portfolios from 4 observations.

Valid values are not changed: the arrays, data frames, nested lists and integer
arrays that are accepted give the same results, and the existing GLS tests pass
unchanged.

test_sigma_validation.py checks each error and the accepted inputs, and that
the second stage uses the sigma that was given. The docstrings of sigma in the
class and in from_formula describe the requirements.
_str, the number formatter of the summaries of the IV, panel, system and asset
pricing results, takes the base 10 logarithm of the absolute value to choose the
format, and int(np.ceil(np.inf)) raised

    OverflowError: cannot convert float infinity to integer

A parameter with a standard error of 0 has an infinite t-statistic, which made
summary fail. A system whose equation fits exactly has a standard error of 0:

    SUR({"a": {"dependent": 2 * x, "exog": x}, ...}).fit(method="ols").summary

_str now formats an infinite value with the format of the large numbers,
"     inf" and "    -inf", which have the width of the other numbers. Missing
values are blank, as before, and the format of the finite values does not change.

test_io.py checks the two infinities, the width, the finite formats that did not
change, and the summary of a system with a zero standard error.
…orModel.fit

The check that the covariance can be debiased moves from the first statement of
LinearFactorModel.fit to after the sizes of the model are unpacked. There is no
change in behavior: the check still runs before any computation. It leaves the
line that unpacks the sizes unchanged, which another change edits.
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.62%. Comparing base (9fa3a90) to head (2366ab2).
⚠️ Report is 53 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #802      +/-   ##
==========================================
+ Coverage   99.60%   99.62%   +0.01%     
==========================================
  Files         136      143       +7     
  Lines       26057    28244    +2187     
  Branches     1873     1985     +112     
==========================================
+ Hits        25954    28138    +2184     
- Misses         38       41       +3     
  Partials       65       65              
Flag Coverage Δ
adder 99.61% <100.00%> (+0.02%) ⬆️
subtractor 99.61% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bashtage
bashtage merged commit 05b7903 into main Oct 13, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant