Repository navigation
BUG: LQMarkov / solve_discrete_riccati_system averages over next-period states outside the inverse #982
Description
Summary
solve_discrete_riccati_system (used by LQMarkov.stationary_values) iterates on the wrong Riccati recursion. It averages the quadratic correction term over next-period Markov states outside the matrix inverse. Because the control
As a result, LQMarkov returns value matrices Ps that are not the cost of any policy, and decision rules Fs that are suboptimal. The error is zero only when each row of
The problem is present in 0.10.1 and on current main (0.13.0, 70fa215).
The correct recursion
With
gives
What the code does
QuantEcon.py/quantecon/_matrix_eqn.py
Lines 306 to 313 in 70fa215
| for j in range(m): | |
| sum1 += beta * Π[i, j] * As[i].T @ Ps[j] @ As[i] | |
| sum2 += Π[i, j] * \ | |
| (beta * As[i].T @ Ps[j] @ Bs[i] + Ns[i].T) @ \ | |
| solve(Qs[i] + beta * Bs[i].T @ Ps[j] @ Bs[i], | |
| beta * Bs[i].T @ Ps[j] @ As[i] + Ns[i]) | |
| Ps1[i][:, :] = Rs[i] + sum1 - sum2 |
for j in range(m):
sum1 += beta * Π[i, j] * As[i].T @ Ps[j] @ As[i]
sum2 += Π[i, j] * \
(beta * As[i].T @ Ps[j] @ Bs[i] + Ns[i].T) @ \
solve(Qs[i] + beta * Bs[i].T @ Ps[j] @ Bs[i],
beta * Bs[i].T @ Ps[j] @ As[i] + Ns[i])
Ps1[i][:, :] = Rs[i] + sum1 - sum2So sum2 is
The F formula in LQMarkov.stationary_values already uses _lqcontrol.py L536–545), which is correct. But it is evaluated at the wrong Ps.
Reproducer
The script below computes LQMarkov's solution and the solution of the correct recursion. It then evaluates the exact discounted cost of each policy by iterating on
lqmarkov_repro.py
import numpy as np
import quantecon as qe
rng = np.random.default_rng(0)
n, k, m, β = 2, 1, 2, 0.95
Π = np.array([[0.5, 0.5],
[0.3, 0.7]])
As = [np.array([[1.0, 0.1], [0.0, 0.9]]), np.array([[0.9, 0.3], [0.2, 1.0]])]
Bs = [np.array([[1.0], [0.0]]), np.array([[0.2], [1.0]])]
Rs = [np.eye(n), 2 * np.eye(n)]
Qs = [np.array([[0.1]]), np.array([[5.0]])]
Ns = [np.zeros((k, n)), np.zeros((k, n))]
lq = qe.LQMarkov(Π, Qs, Rs, As, Bs, Ns=Ns, beta=β)
Ps_lib, ds_lib, Fs_lib = lq.stationary_values()
def correct_riccati(tol=1e-12, max_iter=10_000):
"Iterate P_i = R_i + β A_i' P̄_i A_i - (β B_i' P̄_i A_i + N_i)'(Q_i + β B_i' P̄_i B_i)^{-1}(...)."
Ps = np.array([np.eye(n) for _ in range(m)])
for _ in range(max_iter):
P_bar = np.einsum('ij,jkl->ikl', Π, Ps)
Fs = np.array([np.linalg.solve(Qs[i] + β * Bs[i].T @ P_bar[i] @ Bs[i],
β * Bs[i].T @ P_bar[i] @ As[i] + Ns[i]) for i in range(m)])
Ps_new = np.array([Rs[i] + β * As[i].T @ P_bar[i] @ As[i]
- (β * Bs[i].T @ P_bar[i] @ As[i] + Ns[i]).T @ Fs[i] for i in range(m)])
if np.max(np.abs(Ps_new - Ps)) < tol:
return Ps_new, Fs
Ps = Ps_new
def policy_cost(Fs, tol=1e-12, max_iter=10_000):
"Matrices V_i with x' V_i x = expected discounted cost of following u = -F_{s} x from state (x, i)."
Vs = np.zeros((m, n, n))
for _ in range(max_iter):
V_bar = np.einsum('ij,jkl->ikl', Π, Vs)
Vs_new = np.array([Rs[i] + Fs[i].T @ Qs[i] @ Fs[i] - Fs[i].T @ Ns[i] - Ns[i].T @ Fs[i]
+ β * (As[i] - Bs[i] @ Fs[i]).T @ V_bar[i] @ (As[i] - Bs[i] @ Fs[i])
for i in range(m)])
if np.max(np.abs(Vs_new - Vs)) < tol:
return Vs_new
Vs = Vs_new
Ps_cor, Fs_cor = correct_riccati()
V_lib, V_cor = policy_cost(Fs_lib), policy_cost(Fs_cor)
np.set_printoptions(precision=4, suppress=True)
print("quantecon", qe.__version__)
print("Fs (LQMarkov): ", Fs_lib.reshape(m, -1))
print("Fs (correct): ", Fs_cor.reshape(m, -1))
print("max |Ps_lib - V_lib| =", np.abs(Ps_lib - V_lib).max(),
" (LQMarkov's P is not the cost of its own policy)")
print("max |Ps_cor - V_cor| =", np.abs(Ps_cor - V_cor).max())
for i in range(m):
print(f"state {i}: eigenvalues of V_lib - V_cor =", np.linalg.eigvalsh(V_lib[i] - V_cor[i]))Output (identical on 0.10.1 and main):
Fs (LQMarkov): [[0.9644 0.3243]
[0.2439 0.5083]]
Fs (correct): [[0.9652 0.3295]
[0.2486 0.5117]]
max |Ps_lib - V_lib| = 0.12200161184806113 (LQMarkov's P is not the cost of its own policy)
max |Ps_cor - V_cor| = 2.1316282072803006e-14
state 0: eigenvalues of V_lib - V_cor = [0. 0.0002]
state 1: eigenvalues of V_lib - V_cor = [0. 0.0006]
-
LQMarkov'sPsdiffer from the true cost of its ownFsby 0.12. The correct recursion'sPsequal its policy's cost to machine precision. -
$V^{\text{lib}}_i - V^{\text{cor}}_i$ is positive semidefinite and nonzero, soLQMarkov's policy is strictly costlier than the optimal one.
Suggested fix
Inside the loop over i, form P_bar = sum_j Π[i, j] * Ps[j] and use the single-state Riccati update with P_bar. That is, replace sum1/sum2 with
P_bar = np.tensordot(Π[i], Ps, axes=1)
Ps1[i] = (Rs[i] + beta * As[i].T @ P_bar @ As[i]
- (beta * Bs[i].T @ P_bar @ As[i] + Ns[i]).T
@ solve(Qs[i] + beta * Bs[i].T @ P_bar @ Bs[i],
beta * Bs[i].T @ P_bar @ As[i] + Ns[i]))The reference values in TestLQMarkov.test_stationary_scalar / test_stationary_mat (and the sequence tests that depend on Fs) were presumably generated by the current recursion, so they will need to be regenerated. A test that checks Ps against the policy-evaluation fixed point, as in the script above, would guard against a regression.
Context
Found by Tom Sargent while writing the new policy_regimes lecture. markov_jump_lq in lecture-python-advanced now states the correct equations and notes the discrepancy (QuantEcon/lecture-python-advanced.myst#392). Once this is fixed and released, those notes can be removed.
🤖 Generated with Claude Code
Activity
FIX: put the next-state expectation inside the inverse in solve_discr…
mmcky commentedon Oct 17, 2026
Fixed by #983 and released in v0.13.1, which is now on PyPI and conda-forge. QuantEcon/lecture-python-advanced.myst#393 removes the notes about the old recursion from
markov_jump_lqandpolicy_regimes.