Skip to content

virgil.tempering

Tempering an evidence for correlated spectral channels: the effective number of independent channels from the within-chain covariance of the residuals of neighbouring channels, and the factor f = N / N_eff by which the log likelihood of fit_orbit_aliases is divided.

The problem. Spectral channels of one baseline (and of one closure triangle) are not independent measurements: the same atmosphere, the same calibrator and the same detector rows sit under all of them. A likelihood that treats N channels as N independent points is therefore too sharp by the factor f = N / N_eff. On real data (χ²/N ≈ 12 in virgil-validation) the alias band with the best evidence got p = 1.000, and every posterior was too narrow (sky-plane offset/spread 3.7 to 61).

The cure is a tempered likelihood, L^(1/f): the log likelihood, and with it every evidence difference and posterior curvature, is divided by f (so the posterior is wider by √f). It is applied per observable block, dof_b = 1 / f_b, through the block_dof argument of estimate_primary_diameter and marginal_loglike and the traced dof of the orbit engine; with f = 1 nothing changes.

The estimator

f comes from the residuals of the best static fit of each night, not from χ²: the error scale already absorbs a global underestimate of the errors (χ²/N = 12 alone says the errors are 3.5 times too small, which the per-block scale integrates out, not that the channels are correlated), whereas correlation across channels is what the scale cannot absorb. The channels of one baseline (or triangle) and frame form a chain of n consecutive wavelengths. f is the empirical within-chain covariance factor, pooled over the chains of a block,

f = Σ_chains (Σ_i z_i)² / Σ_chains Σ_i z_i²  =  Var(Σ_chain z) / Σ Var(z),

for whitened residuals z (zero mean). It is the ratio of the variance of the chain's sum to the sum of the variances, which is what N / N_eff means for a mean over the chain: exactly f for any stationary correlation, with no model of its shape. It is exact for AR(1) chains (where it is 1 + 2 Σ (1 - k/n) ρ^k) and for common-mode noise (a calibration error shared by all channels, correlation c at every lag: f = 1 + (n - 1) c) alike; an AR(1) estimate from the lag-1 correlation alone undercounts common-mode correlation (n = 8, c = 0.6: 3.2 for a truth of 5.2). It is scale free (inflating the errors cancels) and never exceeds n. White noise has f = 1 on average.

Biased up on purpose

f used is max(f̂, 1): no threshold is applied to f̂, so white noise gives a f̂ scattering about 1 by √(2 / chains) and the floor raises its mean slightly: the safe direction, as the evidence is then a little cautious rather than overconfident. The unfloored values are kept (ChannelFactors.raw).

Diagnostics, not inputs

The lag-1 and lag-2 correlations ρ1 and ρ2 of each block are kept (ChannelFactors.rho, .rho2). An AR(1) chain has ρ2 = ρ1²; a flat correlation (common mode, or a model error that is smooth in wavelength) has ρ2 ≈ ρ1. flat_signature flags ρ2 well above ρ1²: that is also the signature of misfit (a resolved primary fitted as a point, a drifting companion), which tempering absorbs by widening the posterior instead of repairing the model, so the result warns and sets AliasResult.tempering.

What tempering by 1/f is exact for

Dividing the log likelihood by f is exact for signals that are flat across the channels of a chain (a position or a flux, which every channel measures alike), since the sum of the chain then carries the information of n / f points. It is conservative for chromatic templates (a signal that varies across the chain is partly measured by the differences between channels, which correlated noise affects less than it affects their sum).

Blocks with fewer than MIN_CHAINS chains of their own, without a channel axis (extra observables) or whose residuals the layout cannot reach (closure phases with with_closure_offsets) take the pooled f of all chains (ChannelFactors.fallback). Data with a single wavelength have no chain and f = 1: the tempering is then off, and a fixed f is the way to temper them.

Tempering never sharpens

A significantly negative correlation (differenced or over-fitted residuals) gives f̂ below 1, which would make every evidence gap and posterior more confident than the untempered fit. The f used is at least 1; the raw value is kept so that the result is still flagged (AliasResult.tempering) when f is above FLAG_RANGE[1] or the raw value below FLAG_RANGE[0].

Tempering an evidence for correlated spectral channels.

Provisional

May change in any 0.4.x release without a deprecation cycle.

The effective-channel factor f = N / N_eff is estimated from the within-chain covariance of the residuals of neighbouring channels, and the log likelihood is divided by it (dof_b = 1 / f_b). The derivation, the estimator and its limits are in docs/api/tempering.md; the solved problem is in the AGENTS.md table. This module imports nothing from virgil.

FLAG_RANGE = (0.5, 2.0) module-attribute

MIN_CHAINS = 10 module-attribute

ChannelFactors dataclass

The effective-channel factors of one run.

Attributes:

Name Type Description
f float

Pooled factor N / N_eff (at least 1; 1 without chains), used where a block has no estimate of its own.

blocks dict

{block name: f_b} for every block ("vis", "phi", "extra[k]"): its own estimate with MIN_CHAINS chains, else the pooled f; at least 1.

rho dict

{block name: ρ1}, the lag-1 correlation, plus "pooled" (NaN without chains). Diagnostic only.

rho2 dict

The same for the lag-2 correlation ρ2.

n_pairs dict

{block name: neighbouring pairs}, plus "pooled".

n_chains dict

{block name: chains}, plus "pooled".

n_channels dict

{block name: mean chain length}, plus "pooled".

f_raw float

The lowest unfloored f̂; below 1 means anticorrelated residuals (flagged, not used).

raw dict

{block name: unfloored f̂} of the blocks with chains of their own, plus "pooled".

fallback tuple of str

Blocks that took the pooled f for want of their own estimate.

n_pairs2 dict

{block name: pairs two channels apart}, plus "pooled": the number behind ρ2.

flat tuple of str

The blocks whose lag-2 correlation is not AR(1)'s (see flat_signature).

f instance-attribute

blocks instance-attribute

rho instance-attribute

n_pairs instance-attribute

f_raw = 1.0 class-attribute instance-attribute

n_channels = dataclasses.field(default_factory=dict) class-attribute instance-attribute

rho2 = dataclasses.field(default_factory=dict) class-attribute instance-attribute

n_chains = dataclasses.field(default_factory=dict) class-attribute instance-attribute

raw = dataclasses.field(default_factory=dict) class-attribute instance-attribute

fallback = () class-attribute instance-attribute

flat = () class-attribute instance-attribute

n_pairs2 = dataclasses.field(default_factory=dict) class-attribute instance-attribute

f_max property

The largest f_b: what AliasResult.tempering checks.

__init__(f, blocks, rho, n_pairs, f_raw=1.0, n_channels=dict(), rho2=dict(), n_chains=dict(), raw=dict(), fallback=(), flat=(), n_pairs2=dict())

block_dof()

{block name: 1/f_b}: the block_dof of the tempered score.

estimate_factors(residuals, chains, names=())

The effective-channel factors from residuals at the best fit.

Parameters:

Name Type Description Default
residuals sequence of dict

One dict per dataset, {block name: whitened residuals}, each array in the block's own order (the order channel_chains indexes).

required
chains sequence of dict

One dict per dataset from channel_chains.

required
names sequence of str

Block names to report beyond those of residuals (blocks without residuals, such as extras, take the pooled f).

()

Returns:

Type Description
ChannelFactors

f_b = Σ (Σ_chain z)² / Σ z² pooled over the chains of block b (see docs/api/tempering.md); the pooled f sums all blocks. ρ1 and ρ2 are kept as diagnostics.

Examples:

>>> import numpy as np
>>> z = np.random.default_rng(0).standard_normal(800)
>>> chains = [np.arange(8 * i, 8 * i + 8) for i in range(100)]
>>> 1.0 <= estimate_factors([{"vis": z}], [{"vis": chains}]).f < 1.3
True

channel_chains(data)

The chains of consecutive channels of each block of data.

A chain is the samples that share a baseline (the same u, v in metres) and frame, ordered by wavelength (a closure phase: the same triangle in each channel), as indices into the block's residual vector. Samples are laid out baseline-major with channels fastest, which is how OIData stores several channels, but the chains are found from the u, v, wavel and frame of each sample, not from that order.

Parameters:

Name Type Description Default
data OIData

The data (duck-typed: u, v, wavel, frame, vis, phi, vis_index, phi_index, i_cps1, i_cps2).

required

Returns:

Type Description
dict

{"vis": [chain, ...], "phi": [chain, ...]}: each chain an integer array of at least two samples with strictly increasing wavelength; empty lists for a block with one channel or an unreadable layout.

adjacent_channels(data)

The neighbouring-channel pairs of each block of data: the consecutive samples of channel_chains.

Returns:

Type Description
dict

{"vis": (a, b), "phi": (a, b)}: integer arrays indexing the residual vector of each block, empty when the block has no neighbours or its layout cannot be read.

block_dof(blocks)

The block-keyed dof that tempers each block by its 1/f_b.

Parameters:

Name Type Description Default
blocks dict

{block name: f_b}, e.g. AliasResult.temper_blocks ("vis", "phi", "extra[k]").

required

Returns:

Type Description
dict

{block name: 1/f_b} for the block_dof argument of marginal_loglike and estimate_primary_diameter, so that a later stage (NUTS) reproduces the tempering of the orbit fit. A f_b below 1 is taken as 1: tempering never sharpens.

Examples:

>>> block_dof({"vis": 4.0, "phi": 2.0})
{'vis': 0.25, 'phi': 0.5}

flat_signature(rho1, rho2, n_pairs)

Whether a lag-2 correlation is not AR(1)'s: ρ2 - ρ1² above FLAT_EXCESS and three standard errors (1/√n_pairs).

An AR(1) chain has ρ2 = ρ1². A flat correlation (common mode, a smooth model error) has ρ2 close to ρ1, so the excess is ρ1 (1 - ρ1). It is the signature of misfit as well as of shared calibration error.

out_of_range(f, f_raw=None)

Whether the factor is outside FLAG_RANGE: f (the largest one used) above its top, or the raw (unfloored) f_raw below its bottom.