Add unit tests for the fitting math in fit.py - #70
Conversation
Add breads/tests/test_fit.py, a suite of 54 tests covering fitfm, log_prob, combined_log_prob, nlog_prob and the _get_lsq_fit helper. The tests use small analytic mock datasets (a line, a Gaussian on a constant background, a quadratic) with known true parameters, and check the numerical results against independently computed reference values. Coverage includes parameter recovery, uncertainty calibration, chi-square behaviour, the analytic log-probability expressions of Ruffio+2019 Eq. 36 and its H0 counterpart, the regularization branch, degenerate and invalid inputs, and the wrapper functions. Two tests fail deliberately, flagging a bug in the error bars: fitfm computes covphi = noise_scaling * iMTM, but the parameter covariance scales with the noise variance, so it should be noise_scaling**2 * iMTM. As implemented, scale_noise=True inflates uncertainties by rchi2**0.25 instead of rchi2**0.5, under-reporting them by ~74% in a case where the quoted noise is 3x too small. The failing tests assert the conventionally correct behaviour: - test_uncertainties_scaled_by_noise_scaling_factor - test_uncertainties_are_statistically_calibrated A passing positive control, test_uncertainties_calibrated_when_noise_correctly _specified, exercises the same Monte-Carlo machinery on the unaffected scale_noise=False path to show the test harness itself is sound. Several other behaviours are documented in comments on passing tests rather than asserted as failures: the double division of chi2 in _get_lsq_fit when N_data is None (which suppresses log_prob differences by a factor of N_data); _compute_H0 always using noise_scaling=1 while the H1 branch uses the fitted value; combined_log_prob ignoring its own bounds argument and applying the non-linear prior once per data object; and the use of `raise Warning(...)`, which makes those two guards hard errors rather than soft warnings. Also correct the fitfm docstring, which described the third return value as "s2: noise scaling factor" when it is in fact the reduced chi squared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
FYI. I had Claude Fable 5 review this PR developed by Claude Opus 5, following the recommended pattern of "have the higher-capability model review code generated by a model with lower capability". That review correctly noted the two failing tests, and suggested they be marked as Finding: the two intentional failures will keep CI permanently red
What was verified as sound
|
|
Looking at the issues: In general, I have refactored fitfm() in jb-dev compared to main to try to improve clarity a bit. 1/ Same thing for N_data division issu in _get_lsq_fit, it is already fixed in jb-dev. 2/ I have removed the H0 mode entirely since it is no longer really used. It was some heritage from early BREADS. 3/ The condition with the raise warning has also been removed in jb-dev at this time. 4/ and 5/ Notifying @ben-sappey for combined_log_prob since I believe you added it. We can think about how to implement this better, but I also wondering about letting the user figure it out TBD |
|
What I can do later is to rebase this branch into jb-dev and see if those tests pass there. |
|
For now, since these are pretty easy fixes, we could also just manually copy those specific fixes from jb-dev to main. Yes? That would likely be faster. |
|
Thanks for those fixes, did you check that AI is happy with the new updates and the tests run through? happy to merge this if so. |
|
Still getting some additional errors - debugging now |
The covphi error-bar scaling and the _get_lsq_fit(N_data=None) double division that the suite originally flagged were fixed upstream (commit 27b3e95, merged via d07c905). Update the five affected reference computations so they assert the corrected behavior: - _get_lsq_fit(N_data=None) now returns the plain sum of squared residuals as chi2; test renamed to test_get_lsq_fit_with_none_n_data_returns_textbook_chi2. - log_prob, log_prob_H0, Bayes-factor and marginalize references use the full chi2 (no extra /N_data). Bayes-factor thresholds updated to the now-unsuppressed likelihood ratio. All 54 tests pass; the two intentionally-failing covphi tests now pass naturally against the fixed code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Tests and doc strings slightly updated to take into account the upstream fixes of the identified issues in #71 . All tests now pass. Here's an updated version of the Copilot-generated PR summary: Add Test Suite for fit.py MathematicsSummaryAdds Coverage includes parameter recovery, uncertainty calibration, chi-square behaviour, the analytic log-probability expressions of Ruffio+2019 Eq. 36 and its H0 counterpart, the regularization branch, degenerate and invalid inputs, and the wrapper functions. The forward models used as fixtures follow the conventions of Current status: all 54 tests pass. The 5 pre-existing tests in Two bugs found and fixedWhile developing this suite, two mathematical bugs in 1. Linear-parameter uncertainties were under-inflated
covphi = noise_scaling * iMTM # buggywhere Tests 2.
|
| # | Test | Type | Verifies | Status |
|---|---|---|---|---|
| Parameter recovery | ||||
| 1 | test_linear_model_recovers_tutorial_values |
+ | Tutorial dataset yields the weighted least-squares line (2.19, −0.35) | pass |
| 2 | test_noiseless_gaussian_exact_recovery |
+ | Noise-free Gaussian+background: amplitude and background exact, rchi2 ≈ 0 | pass |
| 3 | test_polynomial_three_linear_parameters_exact_recovery |
+ | Three-column design matrix solved exactly | pass |
| 4 | test_linear_parameters_match_normal_equations |
+ | linparas == inv(MᵀM) Mᵀd for the noise-normalized system |
pass |
| 5 | test_noisy_gaussian_recovers_truth_within_uncertainty |
+ | Noisy data: fitted parameters within 3σ of truth | pass |
| Uncertainties | ||||
| 6 | test_uncertainties_unscaled_match_inverse_normal_matrix |
+ | scale_noise=False gives sqrt(diag(inv(MᵀM))) |
pass |
| 7 | test_uncertainties_scaled_by_noise_scaling_factor |
+ | scale_noise=True inflates errors by sqrt(rchi2) (bug #1, now fixed) |
pass |
| 8 | test_uncertainties_are_statistically_calibrated |
math | 300 Monte-Carlo realizations: reported error matches empirical scatter (bug #1) | pass |
| 9 | test_uncertainties_calibrated_when_noise_correctly_specified |
math | Positive control on the scale_noise=False path |
pass |
| 10 | test_linear_scaling_invariance |
bound | Scaling data and noise by c scales parameters and errors by c | pass |
| 11 | test_heteroscedastic_noise_is_correctly_weighted |
+ | Per-point σ used as inverse weights, not merely an overall scale | pass |
| Chi-square | ||||
| 12 | test_rchi2_near_unity_for_correct_noise_model |
+ | rchi2 ≈ 1 for a well-specified noise vector | pass |
| 13 | test_rchi2_scales_as_square_of_noise_underestimate |
math | σ understated by k inflates rchi2 by k² | pass |
| 14 | test_rchi2_is_unity_when_scale_noise_false |
bound | rchi2 hard-set to 1 in that branch | pass |
| 15 | test_get_lsq_fit_with_explicit_n_data_returns_correct_chi2 |
+ | N_data supplied: correct chi2, rchi2, noise scaling; prior rows excluded |
pass |
| 16 | test_get_lsq_fit_with_none_n_data_returns_textbook_chi2 |
bound | N_data=None: chi2 is the plain sum of squares (bug #2, now fixed) |
pass |
| Log probability and hypothesis testing | ||||
| 17 | test_log_prob_matches_analytic_expression |
math | Reproduces log(Eq. 36) of Ruffio+2019 | pass |
| 18 | test_log_prob_H0_matches_analytic_expression |
math | H0 branch reproduced by hand from M[:, 1:] |
pass |
| 19 | test_log_prob_peaks_at_true_nonlinear_parameter |
math | Grid over μ: argmax at the true centre, noiseless and noisy | pass |
| 20 | test_log_prob_peak_width_scales_with_noise_level |
math | Curvature at the peak: doubling the noise doubles the width | pass |
| 21 | test_bayes_factor_large_with_signal_and_small_without |
+/− | Evidence gain matches its analytic decomposition; separates signal from background | pass |
| 22 | test_marginalize_noise_scaling_matches_analytic_expression |
math | Student-t style branch matches its analytic form; same linear solution | pass |
| 23 | test_marginalize_noise_scaling_peaks_at_true_nonlinear_parameter |
math | Marginalized likelihood also peaks at the truth | pass |
| Regularization (four-output forward models) | ||||
| 24 | test_four_output_fm_without_regularization_matches_three_output |
+ | An extra_outputs dict lacking the key is inert |
pass |
| 25 | test_strong_regularization_pulls_parameter_toward_prior |
math | Tiny s_reg forces the background to its prior value |
pass |
| 26 | test_weak_regularization_recovers_unregularized_solution |
math | Huge s_reg reproduces the unregularized answer |
pass |
| 27 | test_regularization_strength_interpolates_between_limits |
math | Estimate moves monotonically from prior to free solution as s_reg grows |
pass |
| 28 | test_regularization_nan_entries_leave_parameter_unconstrained |
bound | All-NaN s_reg adds no rows; identical to unregularized fit |
pass |
| 29 | test_regularization_does_not_constrain_the_companion_amplitude |
+ | A NaN prior on the first column leaves the companion free | pass |
| 30 | test_regularization_with_scale_noise_false_runs_and_differs |
+ | Both noise-scaling paths execute and give finite, distinct results | pass |
| 31 | test_regularization_with_marginalize_noise_scaling_raises |
− | Incompatible combination is rejected | pass |
| Degenerate, invalid and boundary inputs | ||||
| 32 | test_wrong_number_of_fm_outputs_raises_value_error |
− | A forward model returning 2 values raises ValueError |
pass |
| 33 | test_empty_data_returns_invalid_outputs |
bound | Empty data short-circuits to -inf, -inf, inf, nan[], nan[] |
pass |
| 34 | test_first_column_all_zero_returns_invalid_outputs |
− | Zero companion column: "companion cannot be fitted" path | pass |
| 35 | test_all_zero_columns_are_dropped_and_returned_as_nan |
bound | Dead column reported as NaN; other parameters unchanged | pass |
| 36 | test_singular_design_matrix_returns_invalid_outputs |
− | Duplicate columns: inversion failure trapped, sentinels returned | pass |
| 37 | test_single_linear_parameter_with_computeH0_raises |
− | One-parameter model with computeH0=True raises Warning |
pass |
| 38 | test_single_linear_parameter_with_computeH0_false_works |
bound | Fits successfully; log_prob_H0 is NaN |
pass |
| 39 | test_finite_bounds_raise_warning |
− | Any finite bound raises Warning |
pass |
| 40 | test_infinite_bounds_equivalent_to_none |
bound | Explicit infinite bounds identical to bounds=None |
pass |
| 41 | test_bounds_argument_is_not_mutated |
bound | Caller-supplied bounds are copied, not modified in place | pass |
| Wrapper functions | ||||
| 42 | test_log_prob_matches_fitfm_first_output |
+ | Wrapper returns fitfm(..., computeH0=False)[0] |
pass |
| 43 | test_log_prob_respects_scale_noise_flag |
+ | scale_noise forwarded through to fitfm |
pass |
| 44 | test_log_prob_adds_nonlinear_prior |
+ | Prior added additively and called with the right arguments | pass |
| 45 | test_log_prob_prior_can_veto_a_parameter |
bound | A -inf prior drives the total to -inf |
pass |
| 46 | test_log_prob_returns_neg_inf_when_fm_raises |
− | Exceptions swallowed and reported as -inf |
pass |
| 47 | test_log_prob_handles_single_linear_parameter_without_raising |
bound | computeH0=False internally, so one-parameter models return finite values |
pass |
| 48 | test_nlog_prob_is_negative_of_log_prob |
+ | Sign inversion, with and without a prior | pass |
| 49 | test_nlog_prob_minimized_at_true_parameter |
math | Minimum over the μ grid at the truth | pass |
| 50 | test_combined_log_prob_sums_individual_log_probs |
+ | Sum over data objects | pass |
| 51 | test_combined_log_prob_single_dataset_matches_log_prob |
+ | One-element list degenerates to log_prob |
pass |
| 52 | test_combined_log_prob_applies_prior_once_per_dataset |
bound | Prior counted N times (behaviour 4 above) | pass |
| 53 | test_combined_log_prob_peaks_at_true_parameter |
math | Combining two datasets still peaks at the truth | pass |
| 54 | test_combined_log_prob_ignores_its_bounds_argument |
bound | bounds never forwarded (behaviour 3 above) |
pass |
Running the tests
pytest breads/tests/test_fit.py -vAll 54 tests pass on the current branch (both bugs are fixed), so CI
(ci_test_workflow.yml) is green.
|
Thanks Marshall! |
This pull request was authored mostly by Claude Opus 5 via GitHub Copilot, instructed and supervised by me @mperrin.
Add Test Suite for fit.py Mathematics
Summary
Adds
breads/tests/test_fit.py, a suite of 54 tests coveringfitfm,log_prob,combined_log_prob,nlog_prob, and the_get_lsq_fithelper. The tests use small analytic mock datasets (a straight line, a Gaussian on a constant background, a quadratic) with known true parameters, and check the numerical results against independently computed reference values. The functions under test are never mocked.Coverage includes parameter recovery, uncertainty calibration, chi-square behaviour, the analytic log-probability expressions of Ruffio+2019 Eq. 36 and its H0 counterpart, the regularization branch, degenerate and invalid inputs, and the wrapper functions.
The forward models used as fixtures follow the conventions of
breads.fm.template.templatefmand the tutorial indocs/source/framework/breads_simple_fit_tutorial.ipynb.Current status: 52 pass, 2 fail (due to apparent bugs found during development of these tests) (see below). The 5 pre-existing tests in
breads/tests/are unaffected.Bug found: linear parameter uncertainties are under-inflated
fitfmcomputes the parameter covariance aswhere
noise_scaling = sqrt(rchi2). The parameter covariance scales with the variance of the noise, so the correct rescaling of the covariance matrix isnoise_scaling ** 2 * iMTM, i.e.rchi2 * iMTM. As implemented,scale_noise=Trueinflates the returned uncertainties byrchi2 ** 0.25instead of the conventionalrchi2 ** 0.5.The practical effect: whenever
rchi2 > 1, the error bars reported byfitfmare too small. In a test case where the quoted noise is 3x smaller than the true scatter, the reported uncertainty is ~74% too small:fitfmTwo tests assert the conventionally correct behaviour and therefore fail until this is addressed:
test_uncertainties_scaled_by_noise_scaling_factor— returns[0.0945, 0.0354], correctvalue is
[0.1562, 0.0586]test_uncertainties_are_statistically_calibrated— Monte-Carlo calibration check aboveA passing positive control,
test_uncertainties_calibrated_when_noise_correctly_specified, exercises the same Monte-Carlo machinery on the unaffectedscale_noise=Falsepath, demonstrating that the test harness itself is sound and that the two failures point at the code rather than the tests.The proposed one-line fix is
covphi = noise_scaling ** 2 * iMTM. This PR does not apply that fix, so that the change can be reviewed as a deliberate decision.Other behaviours documented (in comments on passing tests, not asserted as failures)
_get_lsq_fitdivides chi2 twice whenN_data is None. In that branch the codecomputes
chi2 = nansum(residuals**2) / N_dataand thenrchi2 = chi2 / N_data. Thereturned
chi2is therefore already a reduced chi squared, andrchi2(and hencenoise_scaling) is divided byN_dataa second time. TheN_data-supplied branch iscorrect; both paths are tested side by side.
Consequence:
fitfmcalls_get_lsq_fitwithN_data=Noneat line 106, so thechi2entering the log-probability expression is really
chi2 / N_data. This does not shift thelocation of the log-probability maximum, so grid searches are unaffected, but it does
suppress differences in
log_prob. For a 111-sigma companion where the true Δchi2 is12946 (log-evidence gain ~6500),
log_prob - log_prob_H0reports only 21.2. Anyabsolute significance or Bayes factor derived from
log_prob - log_prob_H0is thereforeoff by roughly a factor of
N_data. Therchi2returned byfitfmis unaffected,because it is recomputed independently from
residuals[:N_data].H1 and H0 use inconsistent noise scaling.
fitfmnever passesnoise_scalingto_compute_H0, which therefore always uses its default of 1, while the H1 branch uses thefitted value. With
scale_noise=Truethe two hypotheses are not on the same footing: onpure background data containing no companion at all, this yields
log_prob - log_prob_H0 = +7.8, spuriously favouring a companion, purely from themismatched
-((Nd - Np) / 2) * ln(noise_scaling**2)term.test_bayes_factor_large_with_signal_and_small_withoutis therefore written withscale_noise=False, where both branches are consistent. It then matches an analyticdecomposition (Occam term + Δchi2 / 2 N_data) to 1e-8 and cleanly separates signal
(+29.1) from background (-3.1).
raise Warning(...)is a hard error, not a soft warning.Warningis an exceptionclass, so the two guards at lines 57 and 65 raise rather than warn. Consequences: (a) any
forward model with a single linear parameter must pass
computeH0=Falseexplicitly —including the Gaussian example in the breads tutorial, whose cell 26 raises as written;
(b) the
boundsargument is effectively unusable, since any finite bound raises.@mperrin notes, it may be the case that we do want this to raise an exception; I don't see how it makes sense to have
computeH0=Truefor a case with only a single linear parameter. Should this be a different more specific exception class instead ofWarning, maybeValueErrororRuntimeError? Should we update the tutorial notebook to setcomputeH0=Falsein the cell that currently errors?combined_log_probsilently ignores its ownboundsargument. The implementationhard-codes
bounds=Nonein the per-datasetlog_probcall, so a caller-supplied boundstuple has no effect.
combined_log_probapplies the non-linear prior once per data object. The prior isforwarded into each per-dataset
log_probcall, so with N data objects it is counted Ntimes, i.e. effectively raised to the Nth power.
Docstring correction
The only change to
fit.pyin this PR: thefitfmdocstring described the third return value ass2: noise scaling factor, when it is in fact the reduced chi squared. Corrected torchi2: Reduced chi squared of the best fit. Equal to 1 by definition if scale_noise is False.Test case table
Type key: + positive path (tests something works as intended), − negative path (tests something errors as expected for the given inputs), bound boundary condition, math mathematical identity or statistical property.
test_linear_model_recovers_tutorial_valuestest_noiseless_gaussian_exact_recoverytest_polynomial_three_linear_parameters_exact_recoverytest_linear_parameters_match_normal_equationslinparas == inv(MᵀM) Mᵀdfor the noise-normalized systemtest_noisy_gaussian_recovers_truth_within_uncertaintytest_uncertainties_unscaled_match_inverse_normal_matrixscale_noise=Falsegivessqrt(diag(inv(MᵀM)))test_uncertainties_scaled_by_noise_scaling_factorscale_noise=Trueshould inflate errors bysqrt(rchi2)test_uncertainties_are_statistically_calibratedtest_uncertainties_calibrated_when_noise_correctly_specifiedscale_noise=Falsepathtest_linear_scaling_invariancetest_heteroscedastic_noise_is_correctly_weightedtest_rchi2_near_unity_for_correct_noise_modeltest_rchi2_scales_as_square_of_noise_underestimatetest_rchi2_is_unity_when_scale_noise_falsetest_get_lsq_fit_with_explicit_n_data_returns_correct_chi2N_datasupplied: correct chi2, rchi2, noise scaling; prior rows excludedtest_get_lsq_fit_with_none_n_data_divides_chi2_twiceN_data=None: documents the extra division (behaviour 1 above)test_log_prob_matches_analytic_expressiontest_log_prob_H0_matches_analytic_expressionM[:, 1:]test_log_prob_peaks_at_true_nonlinear_parametertest_log_prob_peak_width_scales_with_noise_leveltest_bayes_factor_large_with_signal_and_small_withouttest_marginalize_noise_scaling_matches_analytic_expressiontest_marginalize_noise_scaling_peaks_at_true_nonlinear_parametertest_four_output_fm_without_regularization_matches_three_outputextra_outputsdict lacking the key is inerttest_strong_regularization_pulls_parameter_toward_priors_regforces the background to its prior valuetest_weak_regularization_recovers_unregularized_solutions_regreproduces the unregularized answertest_regularization_strength_interpolates_between_limitss_reggrowstest_regularization_nan_entries_leave_parameter_unconstraineds_regadds no rows; identical to unregularized fittest_regularization_does_not_constrain_the_companion_amplitudetest_regularization_with_scale_noise_false_runs_and_differstest_regularization_with_marginalize_noise_scaling_raisestest_wrong_number_of_fm_outputs_raises_value_errorValueErrortest_empty_data_returns_invalid_outputs-inf, -inf, inf, nan[], nan[]test_first_column_all_zero_returns_invalid_outputstest_all_zero_columns_are_dropped_and_returned_as_nantest_singular_design_matrix_returns_invalid_outputstest_single_linear_parameter_with_computeH0_raisescomputeH0=TrueraisesWarningtest_single_linear_parameter_with_computeH0_false_workslog_prob_H0is NaNtest_finite_bounds_raise_warningWarningtest_infinite_bounds_equivalent_to_nonebounds=Nonetest_bounds_argument_is_not_mutatedtest_log_prob_matches_fitfm_first_outputfitfm(..., computeH0=False)[0]test_log_prob_respects_scale_noise_flagscale_noiseforwarded through tofitfmtest_log_prob_adds_nonlinear_priortest_log_prob_prior_can_veto_a_parameter-infprior drives the total to-inftest_log_prob_returns_neg_inf_when_fm_raises-inftest_log_prob_handles_single_linear_parameter_without_raisingcomputeH0=Falseinternally, so one-parameter models return finite valuestest_nlog_prob_is_negative_of_log_probtest_nlog_prob_minimized_at_true_parametertest_combined_log_prob_sums_individual_log_probstest_combined_log_prob_single_dataset_matches_log_problog_probtest_combined_log_prob_applies_prior_once_per_datasettest_combined_log_prob_peaks_at_true_parametertest_combined_log_prob_ignores_its_bounds_argumentboundsnever forwarded (behaviour 4 above)Running the tests
Note that CI (
ci_test_workflow.yml) will report a failure on this branch until thecovphiscaling is addressed, since tests 7 and 8 are intended to flag that bug.