Skip to content

Cross-validation of a hyperparameter

Provisional

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

virgil.crossval chooses a hyperparameter, such as the weight λ of a regularizer, by k-fold cross-validation: fit with the setting on the training points of each fold and score the held-out points. It works with any likelihood term that takes a boolean mask of the points to use; ComplexVisData.term is one.

The score

For each (setting, fold) the held-out score is the negative log likelihood of the held-out points under the training fit, up to constants, ½ Σ r² + log_norm, with the fitted values, error scales included (r the whitened residual and log_norm the term's normalization, Σ 2 ln s_d for radio, over the held-out points only). The record also has the raw χ² at s = 1, if you give unit_scale, and the same sums per group, if you give groups (uv annuli, ddids). A smaller score is better.

A radio λ sweep

import numpyro.distributions as dist
from virgil import CalibrationNuisance, ComplexVisData, Image
from virgil.imaging import MaxEntropy
from virgil.crossval import cross_validate
from virgil.radio import dartboard_folds

data = ComplexVisData.read_ms("target.ms", stride=100)
nuis = CalibrationNuisance(data)
fold, annulus = dartboard_folds(data, k=5)

def scene(values):
    return Image(values["log_brightness"], pixel_scale_mas=2.0)

def model(**values):  # defined once: a new function recompiles the fit
    return scene(values)

def fit_kwargs(weight):
    priors = nuis.priors()
    priors["log_brightness"] = dist.Normal(0.0, 3.0)  # per pixel, as in the radio guide
    return dict(model=model, priors=priors, data=(), regularizers=[MaxEntropy(weight)])

def unit_scale(values):  # the error scales set to 1, for the raw chi^2
    return {**values, "cal.sigma": values["cal.sigma"] * 0 + 1}

out = cross_validate(
    [1e-4, 1e-3, 1e-2, 1e-1, 1.0], 5, fold,
    fit_kwargs=fit_kwargs,
    make_term=lambda mask: data.term(scene, nuis, mask=mask),
    init=init,                      # e.g. a fit to all the data
    groups=annulus,
    unit_scale=unit_scale,
    fit_ft={"eps_f64": 1e-6},       # fast transform for the fits
    score_ft={"eps_f64": 1e-12},    # accurate transform for the scores
    fit_options={"method": "lbfgs", "time_limit": 3600},
)
out["settings"][out["best"]], out["settings"][out["one_se"]], out["edge"]

The fits stay on the direct radio term (chi2="auto" is switched off for them).

aggregate returns arrays and dicts, with no plotting: settings, fold_scores (settings × folds), score (the sum over folds), mean and sd over folds, se = sd/√K, best (the smallest mean), one_se (the most regularized setting whose mean is within one se of the best), edge (true if either is at an end of the grid, so extend the grid), and per-group curves group_score, group_chi2, group_n summed over folds, with group_best.

The sweep compiles once if every task uses the same model, make_term and regularizer structure, with the setting only in array values (a regularizer's weight, a prior's scale): the folds differ only in the mask's values. set_ft_defaults is read when a function is traced, so keep the same fit_ft and score_ft in a process; both are restored afterwards.

Cluster array jobs

One (setting, fold) per array task: each calls cv_task, writes its record, and a last step aggregates the files.

import itertools, sys
from virgil.crossval import cv_task, save_record, aggregate_files

settings = [1e-4, 1e-3, 1e-2, 1e-1, 1.0]
pairs = list(itertools.product(settings, range(5)))
setting, k = pairs[int(sys.argv[1])]  # SLURM_ARRAY_TASK_ID
record = cv_task(setting, k, fold, fit_kwargs=fit_kwargs,
                 make_term=make_term, init=init, groups=annulus)
save_record(record, f"cv/task_{sys.argv[1]}.json")
import glob
out = aggregate_files(sorted(glob.glob("cv/task_*.json")))

The records are JSON, so aggregate_files gives the same numbers as cross_validate in one process; it raises if a (setting, fold) pair is missing or repeated.

Other data

make_term(mask) returns a likelihood term using the points where mask (length N) is true, with array shapes independent of the mask. Calling it on the values gives whitened residuals as m blocks of N (m = 2 for the real and imaginary parts of a visibility), zero at masked points; an optional has_log_norm and log_norm(values) give the normalization of the unmasked points. Nothing else is assumed.