Weighted Least Squares with Observation Covariance
Most adjustments are written with a diagonal weight matrix, which asserts that the observations are uncorrelated — and for GNSS baseline vectors, that assertion is simply false. This guide, part of least squares adjustment for control networks, implements the full-covariance form: where the correlations come from, how to build a block-diagonal weight matrix from per-baseline covariances, and what changes in the result when the off-diagonal terms are respected.
Where the Correlations Come From
A GNSS baseline is not three independent observations. It is a three-component vector estimated from one set of carrier-phase data, and its components are strongly correlated — typically 0.3 to 0.8 between the horizontal components, and often more between the vertical and the others. The processing software reports a three-by-three covariance for each vector precisely because a triple of standard deviations does not describe it.
Figure — one 3×3 block per baseline; zeros between independently observed vectors.
Ignoring those correlations does two things. It changes the estimates, because the adjustment weights the components as though they were independent evidence when they are partly the same evidence. And it distorts the parameter uncertainties, usually optimistically, because independent observations carry more information than correlated ones. The estimates typically move by a few millimetres; the uncertainties can be wrong by tens of per cent.
with
Complete Runnable Implementation
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy import linalg
@dataclass(frozen=True)
class Baseline:
"""One GNSS vector observation with its full 3x3 covariance."""
from_id: str
to_id: str
dxyz: np.ndarray # (3,) metres
cov: np.ndarray # (3, 3) metres squared
def __post_init__(self) -> None:
if self.dxyz.shape != (3,) or self.cov.shape != (3, 3):
raise ValueError("expected a (3,) vector and a (3, 3) covariance")
if not np.allclose(self.cov, self.cov.T, atol=1e-18):
raise ValueError(f"{self.from_id}->{self.to_id}: covariance not symmetric")
if np.min(np.linalg.eigvalsh(self.cov)) <= 0.0:
raise ValueError(
f"{self.from_id}->{self.to_id}: covariance is not positive definite; "
f"a singular block cannot be inverted into a weight"
)
def block_weight_matrix(baselines: list[Baseline]) -> np.ndarray:
"""Inverse of the block-diagonal observation covariance.
Inverting block by block is not an optimisation: a whole-matrix inverse of a
block-diagonal matrix is numerically worse and O(n^3) in the total size rather
than in the block size.
"""
blocks = [linalg.inv(b.cov) for b in baselines]
return linalg.block_diag(*blocks)
def solve_weighted(design: np.ndarray, observed: np.ndarray,
weight: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Weighted least squares via Cholesky, not by forming the normal inverse.
Returns (parameters, parameter covariance, residuals). Factorising the normal
matrix once serves both the solution and its covariance, and avoids squaring
the condition number twice.
"""
n = design.T @ weight @ design
rhs = design.T @ weight @ observed
try:
c, low = linalg.cho_factor(n)
except linalg.LinAlgError as exc:
raise ValueError(
"the normal matrix is not positive definite: the network geometry does "
"not determine these parameters"
) from exc
x = linalg.cho_solve((c, low), rhs)
cov_x = linalg.cho_solve((c, low), np.eye(n.shape[0]))
residuals = observed - design @ x
return x, cov_x, residuals
def variance_factor(residuals: np.ndarray, weight: np.ndarray,
n_parameters: int) -> float:
"""A posteriori variance factor — near 1 when the stochastic model fits."""
dof = residuals.size - n_parameters
if dof <= 0:
raise ValueError("no redundancy: the variance factor is undefined")
return float(residuals @ weight @ residuals) / dof
Parameter Reference
| Name | Type | Units | Note |
|---|---|---|---|
dxyz |
np.ndarray |
m | Cartesian vector components |
cov |
np.ndarray |
m² | Full 3×3 from the processing software |
design |
np.ndarray |
mixed | (3m, u) for m baselines and u parameters |
weight |
np.ndarray |
m⁻² | Block diagonal; never a scalar |
variance_factor |
float |
— | Tested against chi-square |
Worked Example
import numpy as np
cov = np.array([[4.0e-6, 1.8e-6, -0.9e-6],
[1.8e-6, 3.6e-6, 0.4e-6],
[-0.9e-6, 0.4e-6, 9.0e-6]]) # sigma ~2, 1.9, 3 mm
bl = Baseline("A", "B", np.array([1234.567, -890.123, 456.789]), cov)
w_full = block_weight_matrix([bl])
w_diag = np.diag(1.0 / np.diag(cov))
print(np.round(np.diag(w_full) / np.diag(w_diag), 3))
# [1.297 1.397 1.106]
Respecting the correlations changes the effective weight of each component by ten to forty per cent — before a single parameter has been estimated. That is the size of the effect the diagonal assumption throws away.
Figure — what respecting the correlations changes, and what it does not.
Validation Check
def assert_weight_is_consistent(baselines: list[Baseline]) -> None:
"""The weight matrix must invert back to the covariance it came from."""
w = block_weight_matrix(baselines)
sigma = linalg.block_diag(*[b.cov for b in baselines])
assert np.allclose(w @ sigma, np.eye(w.shape[0]), atol=1e-9), (
"weight and covariance are not inverses; check for a block that was "
"inverted twice or a unit mismatch between them"
)
Common Mistakes
Building the weight from standard deviations. Squaring is easy to forget, and a weight matrix built from sigmas rather than variances is wrong by the sigmas themselves — which for millimetre values means weights out by three orders of magnitude. The variance factor catches it immediately, which is one more reason to compute it.
Assuming baselines are independent of each other. Vectors from the same session sharing a reference station are correlated between baselines, not just within them. Full session covariance is the rigorous treatment; where it is unavailable, treating baselines as independent is the standard simplification and is worth recording as one.
Inverting the whole covariance at once. A block-diagonal matrix inverts block by block, exactly and cheaply. Forming and inverting the full matrix wastes time on a large network and loses precision on a poorly conditioned one.
Reading a Baseline Covariance
The three-by-three block a processing package reports is more informative than the three standard deviations usually extracted from it, and two properties are worth looking at before an adjustment rather than after.
Figure — screening blocks at import: two numbers that predict a dominating observation.
The correlation pattern. Converting the covariance to correlations shows how much of the vector is really independent evidence. Horizontal-to-horizontal correlations of 0.3 to 0.6 are normal; a correlation above 0.9 between two components means the pair is nearly one observation and the adjustment should be told so rather than left to discover it.
The condition number. A block whose largest eigenvalue is many orders of magnitude above its smallest describes a vector that is well determined in one direction and barely determined in another — a short session, poor satellite geometry, or a constrained component. Inverting such a block produces a weight matrix with an enormous entry, and that single number can dominate an entire adjustment.
def summarise_block(cov: np.ndarray) -> dict[str, float]:
"""Correlations and conditioning of one baseline covariance."""
s = np.sqrt(np.diag(cov))
corr = cov / np.outer(s, s)
eig = np.linalg.eigvalsh(cov)
return {
"sigma_mm": float(s.max() * 1000.0),
"max_correlation": float(np.abs(corr - np.eye(3)).max()),
"condition": float(eig.max() / max(eig.min(), 1e-30)),
}
Screening every block through that function at import time takes milliseconds and surfaces the observations that will otherwise dominate the solution for reasons nobody chose.
Frequently Asked Questions
Where do I get the covariances?
From the GNSS processing software, which reports one per baseline. They are routinely discarded at the import step because a simpler data structure was convenient, and reconstructing them later is impossible — so the import is where to keep them.
What if a covariance block is singular?
That means the software reported a vector one of whose components carries no information — usually a constrained or derived component. It cannot be inverted into a weight, and the honest treatments are to drop that component from the observation or to add a small regularisation and record it. Inverting it with a pseudo-inverse silently invents a weight.
Does the full covariance change the residuals much?
Usually by a few millimetres in a well-conditioned network, and more where baselines are strongly correlated and the geometry is weak. The bigger change is in the parameter uncertainties, which is what the deliverable quotes, and where the diagonal approximation is usually optimistic.
Should I scale the covariances by the variance factor?
Only after concluding that the model is otherwise sound, and then record it. A variance factor well away from one is first a diagnostic — see chi-square testing of transformation residuals — and scaling it away before investigating hides systematic error in an inflated uncertainty.
How do I combine observations of different types in one weight matrix?
By expressing every observation in its own units with its own variance, and letting the weight matrix carry the mix. A network combining GNSS vectors in metres with levelled height differences in metres and directions in radians has a block-diagonal weight matrix whose blocks have entirely different magnitudes, and that is correct rather than a problem — the variance factor is dimensionless precisely so that a heterogeneous adjustment can be tested the same way a homogeneous one is. What must not happen is a single scalar weight applied across types, which silently ranks a millimetre of height against a second of arc.
A practical note on storage: keep the covariance blocks with the observations from the moment they are imported, in the same record rather than in a parallel structure. Every pipeline that loses them lost them at the import step, where a simpler data class looked sufficient, and the information cannot be reconstructed afterwards from the standard deviations alone.