From 6e0f618a14b8e4abd98b91b9dd1b5be556656441 Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 13:27:48 +0100 Subject: [PATCH 1/2] BUG: Report the continuously updated objective as the J statistic of CUE LinearFactorModelGMM(...).fit(use_cue=True) minimizes the continuously updated objective n g' W(theta)^-1 g, where W is the covariance of the moments at theta. It then reported the objective with the weight matrix of the starting values, which is not the statistic that was minimized and is larger than it. In simulated samples with 150 observations, 8 portfolios and 2 factors the reported J was 0.4% to 8% above the minimum of the objective (8.77 against 8.40 in the example of the test). The J statistic of the CUE is now the objective that the optimizer minimized. The test minimizes the objective directly with the moment conditions written out, and compares the minimum with the statistic. It fails without the change. --- linearmodels/asset_pricing/model.py | 6 ++- .../asset_pricing/test_linear_factor_gmm.py | 50 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/linearmodels/asset_pricing/model.py b/linearmodels/asset_pricing/model.py index fd4f2bfddb..e316ede076 100644 --- a/linearmodels/asset_pricing/model.py +++ b/linearmodels/asset_pricing/model.py @@ -1123,7 +1123,11 @@ def fit( sel = slice(0, (n * (k + 1)), (k + 1)) alphas = g.mean(0)[sel, None] alpha_vcv = s[sel, sel] / nobs - stat = self._j(params, excess_returns, w) + if use_cue: + # The objective that was minimized updates the weight matrix + stat = self._j_cue(params, excess_returns, weight_est_instance) + else: + stat = self._j(params, excess_returns, w) jstat = WaldTestStatistic( stat, "All alphas are 0", n - k - nrf, name="J-statistic" ) diff --git a/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py b/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py index de309179b4..aea20d336c 100644 --- a/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py +++ b/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py @@ -1,8 +1,9 @@ import numpy as np from numpy.testing import assert_allclose import pytest +from scipy.optimize import minimize -from linearmodels.asset_pricing.model import LinearFactorModelGMM +from linearmodels.asset_pricing.model import LinearFactorModel, LinearFactorModelGMM from linearmodels.tests.asset_pricing._utility import generate_data, get_all @@ -107,3 +108,50 @@ def test_linear_model_gmm_cue_smoke(data): mod = LinearFactorModelGMM(data.portfolios, data.factors, risk_free=True) res = mod.fit(cov_type="robust", disp=10, use_cue=True) get_all(res) + + +def cue_moments(theta, p, f): + """Moment conditions of the model with excess returns, written out directly""" + nobs, nport = p.shape + nfactor = f.shape[1] + betas = theta[: nport * nfactor].reshape(nport, nfactor) + lam = theta[nport * nfactor : nport * nfactor + nfactor] + mu = theta[nport * nfactor + nfactor :] + fe = f - mu + eps = p - (betas @ lam)[None, :] - fe @ betas.T + fc = np.column_stack([np.ones(nobs), f]) + return np.column_stack([np.hstack([fc * eps[:, [i]] for i in range(nport)]), fe]) + + +def cue_objective(theta, p, f): + g = cue_moments(theta, p, f) + gc = g - g.mean(0) + w = np.linalg.inv(gc.T @ gc / g.shape[0]) + return g.shape[0] * float(g.mean(0) @ w @ g.mean(0)) + + +def test_linear_model_gmm_cue_j_statistic(): + # The J statistic is the minimized value of the continuously updated objective, + # which uses the weight matrix at the estimates and not at the starting values + rng = np.random.default_rng(3) + nobs, nport, nfactor = 150, 8, 2 + f = 0.05 * ( + 0.2 * rng.standard_normal((nobs, 1)) + rng.standard_normal((nobs, nfactor)) + ) + f += np.arange(1, nfactor + 1)[None, :] / 1200 + betas = rng.chisquare(2, (nfactor, nport)) / 2 + p = f @ betas + 0.03 * rng.standard_normal((nobs, nport)) + p += np.arange(nport)[None, :] / nport / 200 + res = LinearFactorModelGMM(p, f).fit(use_cue=True, disp=0) + start = LinearFactorModel(p, f).fit() + theta0 = np.r_[ + np.asarray(start.betas).ravel(), np.asarray(start.risk_premia), f.mean(0) + ] + best = minimize( + cue_objective, + theta0, + args=(p, f), + method="BFGS", + options={"gtol": 1e-9, "maxiter": 5000}, + ) + assert_allclose(res.j_statistic.stat, best.fun, rtol=1e-4) From c948978f35030e64b47fbb360b5705e45b92c257 Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 13:31:00 +0100 Subject: [PATCH 2/2] BUG: Apply opt_options to every minimization in LinearFactorModelGMM.fit opt_options is documented as the options that are passed to scipy.optimize.minimize, and all of its inputs except fun, x0 and args can be overridden. Only the first minimization used it. The later steps of the estimator (steps > 1) and the minimization of the continuously updated objective (use_cue=True) were run with the default optimizer and only maxiter, so that method, tol, bounds, a user callback and the other options were ignored after the first step. For example, opt_options={"method": "Nelder-Mead"} used Nelder-Mead for the first step and BFGS for the second. The options are now passed to every call of minimize, with the same override rules as for the first step. Fits that do not pass opt_options are unchanged. The test records the arguments of every call to minimize for steps=3 and for the CUE, and fails without the change. --- linearmodels/asset_pricing/model.py | 18 ++++++++------- .../asset_pricing/test_linear_factor_gmm.py | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/linearmodels/asset_pricing/model.py b/linearmodels/asset_pricing/model.py index e316ede076..68354d8d8a 100644 --- a/linearmodels/asset_pricing/model.py +++ b/linearmodels/asset_pricing/model.py @@ -1043,17 +1043,21 @@ def fit( # 2. Step 1 using w = inv(s) from SV callback = callback_factory(self._j, args, disp=disp) - _default_options: dict[str, Any] = {"callback": callback} options = {"maxiter": max_iter} opt_options = {} if opt_options is None else opt_options options.update(opt_options.get("options", {})) - _default_options.update(opt_options) - _default_options["options"] = options + + def minimize_kwargs( + callback: Callable[[linearmodels.typing.data.Float64Array], None], + ) -> dict[str, Any]: + # The options of the user apply to every minimization + return {"callback": callback, **opt_options, "options": options} + opt_res = minimize( fun=self._j, x0=np.squeeze(sv), args=args, - **_default_options, + **minimize_kwargs(callback), ) params = opt_res.x last_obj = opt_res.fun @@ -1072,8 +1076,7 @@ def fit( self._j, params, args=args, - callback=callback, - options={"maxiter": max_iter}, + **minimize_kwargs(callback), ) params = opt_res.x obj = opt_res.fun @@ -1088,8 +1091,7 @@ def fit( self._j_cue, params, args=cue_args, - callback=callback, - options={"maxiter": max_iter}, + **minimize_kwargs(callback), ) params = opt_res.x diff --git a/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py b/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py index aea20d336c..370de53eea 100644 --- a/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py +++ b/linearmodels/tests/asset_pricing/test_linear_factor_gmm.py @@ -3,6 +3,7 @@ import pytest from scipy.optimize import minimize +import linearmodels.asset_pricing.model as model_module from linearmodels.asset_pricing.model import LinearFactorModel, LinearFactorModelGMM from linearmodels.tests.asset_pricing._utility import generate_data, get_all @@ -155,3 +156,25 @@ def test_linear_model_gmm_cue_j_statistic(): options={"gtol": 1e-9, "maxiter": 5000}, ) assert_allclose(res.j_statistic.stat, best.fun, rtol=1e-4) + + +@pytest.mark.parametrize("kwargs", [{"steps": 3}, {"use_cue": True}]) +def test_linear_model_gmm_opt_options_every_step(monkeypatch, kwargs): + # The options of the optimizer apply to every minimization of the estimator + data = generate_data(nfactor=1, nportfolio=4, nobs=300, output="numpy") + calls = [] + minimize_original = minimize + + def recording_minimize(*args, **kw): + calls.append(kw) + return minimize_original(*args, **kw) + + monkeypatch.setattr(model_module, "minimize", recording_minimize) + opt_options = {"method": "L-BFGS-B", "tol": 1e-8, "options": {"maxcor": 7}} + mod = LinearFactorModelGMM(data.portfolios, data.factors) + mod.fit(disp=0, opt_options=opt_options, **kwargs) + assert len(calls) >= 2 + for call in calls: + assert call["method"] == "L-BFGS-B" + assert call["tol"] == 1e-8 + assert call["options"] == {"maxiter": 1000, "maxcor": 7}