Skip to content

Commit 00c5b15

Browse files
committed
Fix lint
1 parent 425e54f commit 00c5b15

4 files changed

Lines changed: 288 additions & 159 deletions

File tree

docs/api/rates/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The central concept is the [discount factor](../../glossary.md#discount-factor)
66

77
**[Rate](interest_rate.md)** represents a spot or forward interest rate with a chosen compounding frequency (continuous by default) and day count convention. It supports continuous and periodic compounding and can be bootstrapped directly from a spot/forward pair.
88

9-
**[YieldCurve](yield_curve.md)** is the abstract base for term-structure models. It defines the interface via `discount_factor` and `instantaneous_forward_rate`, with the two quantities linked by
9+
**[YieldCurve](yield_curve.md)** is the abstract base for term-structure models. It defines the interface via [discount_factor][quantflow.rates.yield_curve.YieldCurve.discount_factor] and [instantaneous_forward_rate][quantflow.rates.yield_curve.YieldCurve.instantaneous_forward_rate], with the two quantities linked by
1010

1111
\begin{equation}
1212
f(\tau) = -\frac{\partial \ln D_\tau}{\partial \tau}
@@ -18,4 +18,4 @@ The central concept is the [discount factor](../../glossary.md#discount-factor)
1818

1919
**[VasicekCurve](vasicek.md)** is a Gaussian mean-reverting short-rate model with analytical formulas for discount factors and instantaneous forward rates.
2020

21-
**[Options Discounting](options.md)** provides `YieldCurveCalibration`, the base class for fitting a yield curve to discount factors, and `OptionsDiscountingCalibration`, which bootstraps asset and quote curves from put-call parity observations.
21+
**[Calibration](calibration.md)** provides [YieldCurveCalibration][quantflow.rates.calibration.YieldCurveCalibration], the base class for fitting a yield curve to discount factors, and [OptionsDiscountingCalibration][quantflow.rates.calibration.OptionsDiscountingCalibration], which bootstraps asset and quote curves from put-call parity observations.

docs/theory/kalman.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,11 @@ without user intervention.
239239
When the transition $f(x, \Delta t)$ is non-linear the Kalman predict step
240240
is no longer exact. The unscented Kalman filter (UKF), introduced by
241241
[Julier & Uhlmann (1997)](../bibliography.md#julier_uhlmann), replaces it
242-
with a **sigma-point** propagation. The observation update is unchanged: it
243-
reuses the Kalman update step described above, so the UKF inherits the
244-
Sherman-Morrison optimisation when applicable.
242+
with a **sigma-point** propagation. The observation update follows the same
243+
structure, but builds the innovation covariance $S_t$ and the cross covariance
244+
$C_t$ from the propagated sigma points and solves the gain $K_t = C_t S_t^{-1}$
245+
with a dense Cholesky factorisation. The Sherman-Morrison fast path is specific
246+
to the exact linear filter and is not used here.
245247

246248
### Sigma-Point Predict
247249

quantflow/ta/kalman.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,28 @@ def kalman_filter(
199199
# ---------------------------------------------------------------------------
200200

201201

202+
def isotropic_noise(
203+
R: Annotated[FloatArray, Doc("Observation noise covariance of shape (n_y, n_y).")],
204+
) -> float | None:
205+
r"""Detect isotropic observation noise of the form $R = h^2 I$.
206+
207+
Returns the scalar variance $h^2$ when every diagonal entry equals $h^2$
208+
and all off-diagonal entries are zero, otherwise returns ``None``.
209+
210+
This is the precondition for the Sherman-Morrison fast path in the Kalman
211+
update: when the noise is isotropic and the state is one-dimensional, the
212+
innovation covariance is a rank-1 update to a scaled identity and can be
213+
inverted in $O(n_y)$ instead of $O(n_y^3)$.
214+
"""
215+
n = R.shape[0]
216+
h2 = float(R[0, 0])
217+
if h2 <= 0.0:
218+
return None
219+
if n > 1 and not np.allclose(R, h2 * np.eye(n)):
220+
return None
221+
return h2
222+
223+
202224
class KalmanFilter(BaseModel, arbitrary_types_allowed=True):
203225
r"""Kalman filter for a [LinearGaussianModel][..LinearGaussianModel].
204226
@@ -240,6 +262,23 @@ class KalmanFilter(BaseModel, arbitrary_types_allowed=True):
240262
n_y \log 2\pi + \log\det S_t + e_t^\top S_t^{-1} e_t
241263
\right).
242264
\end{equation}
265+
266+
When the state is one-dimensional and the observation noise is isotropic
267+
($R = h^2 I$), the innovation covariance $S_t = h^2 I + P_{t \mid t-1}\, c c^\top$
268+
is a rank-1 update to a scaled identity, where $c = H$ is a column vector.
269+
270+
The [Sherman-Morrison identity](../../glossary.md#sherman-morrison-identity)
271+
inverts it in $O(n_y)$ instead of $O(n_y^3)$:
272+
273+
\begin{equation}
274+
S_t^{-1} = \frac{1}{h^2}\left(
275+
I - \frac{P_{t \mid t-1}\, c c^\top}{h^2 + P_{t \mid t-1}\, c^\top c}
276+
\right).
277+
\end{equation}
278+
279+
The update detects this case automatically (see
280+
[isotropic_noise][..isotropic_noise]) and applies the fast path; otherwise
281+
it falls back to a dense Cholesky solve.
243282
"""
244283

245284
model: LinearGaussianModel = Field(description="Linear-Gaussian state-space model.")
@@ -334,6 +373,9 @@ def update(
334373
H = model.H[:, None] if model.H.ndim == 1 else model.H
335374
obs = np.atleast_1d(np.asarray(y, dtype=float))
336375
innov = obs - H @ pred.mean
376+
h2 = isotropic_noise(model.R)
377+
if h2 is not None and model.n_x == 1:
378+
return self._update_isotropic(pred, H[:, 0], innov, h2)
337379
S = H @ pred.cov @ H.T + model.R
338380
sign, log_det = np.linalg.slogdet(S)
339381
if sign <= 0:
@@ -345,6 +387,32 @@ def update(
345387
cov = pred.cov - gain @ H @ pred.cov
346388
return MeanAndCov(mean=mean, cov=cov), log_lik
347389

390+
def _update_isotropic(
391+
self,
392+
pred: MeanAndCov,
393+
c: FloatArray,
394+
innov: FloatArray,
395+
h2: float,
396+
) -> tuple[MeanAndCov, float]:
397+
"""Sherman-Morrison fast path of [update][..update] for a 1d state with
398+
isotropic noise.
399+
400+
Equivalent to the dense path but inverts the rank-1 innovation
401+
covariance in closed form. See the class docstring and the theory docs
402+
for the derivation.
403+
"""
404+
n_y = innov.shape[0]
405+
p = float(pred.cov[0, 0])
406+
d = h2 + p * float(c @ c) # h^2 + P c^T c
407+
ce = float(c @ innov) # c^T e_t
408+
log_det = (n_y - 1) * np.log(h2) + np.log(d)
409+
quad = (float(innov @ innov) - p * ce * ce / d) / h2
410+
log_lik = -0.5 * (n_y * np.log(2.0 * np.pi) + log_det + quad)
411+
gain = (p / d) * c # K_t = (P / d) c^T, shape (n_y,)
412+
mean = pred.mean + gain @ innov
413+
cov = np.array([[p * h2 / d]])
414+
return MeanAndCov(mean=mean, cov=cov), log_lik
415+
348416

349417
# ---------------------------------------------------------------------------
350418
# Unscented Kalman filter

0 commit comments

Comments
 (0)