Files
Jungfraujoch/tests/AndersonMixingTest.cpp
T
leonarski_fandClaude Opus 5.5 0894230671 Anderson: accelerate a scaling loop only near its fixed point, and never again once its step grows (RESULT-CHANGING)
The Anderson commit before this one changed three rc176 battery results, which is why it was
reverted there. One of them is the acceleration's own doing: on a split myoglobin sweep (P2_1,
ISa 2.5) the partials loops never settle - one walks at a step of ~2e-2 for as long as it runs -
and the fulls loops walk from their first round (their cross-validated lambda moves every round,
so the map itself is not fixed). Restarting the history whenever the step grew only rebuilt a
one- or two-round history and extrapolated along the walk: the stalled loops ended somewhere
else, the pre-pass fulls smoothing window went from ~7 frames to ~1, and CC1/2 fell 0.701 -> 0.595.

Two rules now confine the acceleration to the regime it is for - a map contracting to its fixed
point:
- a loop is accelerated only once its plain step is below ten times the tolerance (1e-2);
- the first round whose weighted residual grew turns the acceleration off for the rest of the
  loop (AndersonMixing::Stopped), instead of restarting it.
A loop that walks therefore stays the plain loop it was in rc176. The growth rule alone gave
CC1/2 0.625; with the onset, 0.701, as rc176.

Measured against rc176 bc191bb80 (GPU, workstation):
- myob_x06da_split: P2_1, CC1/2 0.701, d 1.81 A - as rc176; 481 -> 257 scaling iterations.
- 7qij 434 -> 126 iterations, 9v16 273 -> 101, same space group and statistics.
- ci tier (29 sets, no null model): 28 unchanged beyond noise; sum of times 580 -> 585 s
  (9gdj +9 s, below).
The two other changes are NOT Anderson's and stay:
- insu_I_x06da_weak I23 -> I222: the all-observations search refuses I23 on a merge chi2 ratio
  2.08 against a 1.85 bound; plain scaling converged to 1e-5 gives 2.07-2.09 and refuses it too.
  Only the plain loop stopped at 1e-3 passed - the bound was set on unconverged scaling.
- 9gdj +9 s, same answer: the early beam-centre comparison's "loses beyond noise" margin
  (-0.002 against 2 sigma 0.0016) moves by ~0.07 between scaling paths, so the converged loop no
  longer takes the shortcut and both centres are judged on all frames; the file's centre is kept
  either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
2026-10-19 18:49:24 +02:00

119 lines
5.5 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cmath>
#include <catch2/catch_all.hpp>
#include "../image_analysis/scale_merge/AndersonMixing.h"
namespace {
// A noiseless sweep of 24 frames whose reflections each span three consecutive frames, scaled by the
// alternating least squares of the per-frame scaling loop: the reference intensities from the
// scales, the scales from the references, the gauge pinned at a geometric mean of 1. A frame is
// tied only to its neighbours, so a smooth error of the scales over the sweep decays by a few
// percent a round - the slow linear convergence of the real loop.
struct SlowScalingLoop {
static constexpr int n_frames = 24, span = 3;
std::vector<int> first; // reflection h is on frames first[h] .. first[h] + span - 1
std::vector<double> I_true, G_true;
SlowScalingLoop() {
for (int h = 0; h + span <= n_frames; ++h)
for (int rep = 0; rep < 3; ++rep) {
first.push_back(h);
I_true.push_back(1.0 + 0.5 * std::sin(1.7 * h + rep));
}
for (int f = 0; f < n_frames; ++f)
G_true.push_back(1.0 + 0.4 * std::sin(6.283 * f / n_frames) + 0.2 * std::cos(2.1 * f));
}
std::vector<double> Round(const std::vector<double> &g) const {
std::vector<double> num(n_frames, 0.0), den(n_frames, 0.0), out(n_frames);
for (size_t h = 0; h < first.size(); ++h) {
double mean = 0.0;
for (int k = 0; k < span; ++k) mean += G_true[first[h] + k] * I_true[h] / g[first[h] + k];
mean /= span;
for (int k = 0; k < span; ++k) {
num[first[h] + k] += G_true[first[h] + k] * I_true[h] * mean;
den[first[h] + k] += mean * mean;
}
}
double s = 0.0;
for (int f = 0; f < n_frames; ++f) { out[f] = num[f] / den[f]; s += std::log(out[f]); }
for (int f = 0; f < n_frames; ++f) out[f] /= std::exp(s / n_frames);
return out;
}
// Rounds to a step below `tolerance`, and the largest |log G - log G_true| there (gauge-pinned).
std::pair<int, double> Run(int depth, double tolerance) const {
std::vector<double> g(n_frames, 1.0), log_g(n_frames), log_g_new(n_frames), w(n_frames, 1.0);
AndersonMixing anderson(depth);
int rounds = 0;
for (double step = INFINITY; step >= tolerance && rounds < 100000; ++rounds) {
std::vector<double> g_new = Round(g);
double ss = 0.0;
for (int f = 0; f < n_frames; ++f) {
log_g[f] = std::log(g[f]);
log_g_new[f] = std::log(g_new[f]);
ss += (log_g_new[f] - log_g[f]) * (log_g_new[f] - log_g[f]);
}
step = std::sqrt(ss / n_frames);
if (depth > 0) {
const std::vector<double> next = anderson.Next(log_g, log_g_new, w);
double s = 0.0;
for (double v : next) s += v;
for (int f = 0; f < n_frames; ++f) g_new[f] = std::exp(next[f] - s / n_frames);
}
g = g_new;
}
double s = 0.0, err = 0.0;
for (double v : G_true) s += std::log(v);
for (int f = 0; f < n_frames; ++f)
err = std::max(err, std::fabs(std::log(g[f]) - (std::log(G_true[f]) - s / n_frames)));
return {rounds, err};
}
};
}
TEST_CASE("AndersonMixing_SlowScalingLoop", "[RotationScale]") {
const SlowScalingLoop loop;
const auto [plain_rounds, plain_err] = loop.Run(0, 1e-6);
const auto [aa_rounds, aa_err] = loop.Run(5, 1e-6);
// The plain loop creeps: hundreds of rounds, and stopped on its step it still sits a hundred
// times the tolerance from the answer.
CHECK(plain_rounds > 300);
CHECK(plain_err > 5e-5);
// Accelerated: the same fixed point, in a tenth of the rounds, and closer to it at the same stop.
CHECK(aa_rounds * 10 < plain_rounds);
CHECK(aa_err < 0.5 * plain_err);
}
TEST_CASE("AndersonMixing_ZeroWeightTakesThePlainStep", "[RotationScale]") {
// x -> 0.9 x on two components, the second not part of the fit.
AndersonMixing anderson(5);
std::vector<double> x{1.0, 1.0};
const std::vector<double> w{1.0, 0.0};
for (int it = 0; it < 4; ++it) {
const std::vector<double> fx{0.9 * x[0], 0.9 * x[1]};
const std::vector<double> next = anderson.Next(x, fx, w);
CHECK(next[1] == fx[1]);
x = next;
}
// A linear map on the fitted component is solved by the first mixed step.
CHECK(std::fabs(x[0]) < 1e-12);
CHECK(anderson.Restarts() == 0);
}
TEST_CASE("AndersonMixing_PlainOnceTheResidualGrows", "[RotationScale]") {
AndersonMixing anderson(5);
const std::vector<double> w{1.0};
CHECK(anderson.Next({1.0}, {0.5}, w)[0] == 0.5); // no history: the plain step
anderson.Next({0.5}, {0.25}, w); // residual fell: history kept
CHECK(!anderson.Stopped());
CHECK(anderson.Next({0.0}, {2.0}, w)[0] == 2.0); // residual grew: the plain step
CHECK(anderson.Stopped());
CHECK(anderson.Next({2.0}, {2.1}, w)[0] == 2.1); // and plain from then on, though it fell again
CHECK(anderson.Next({2.1}, {2.15}, w)[0] == 2.15);
CHECK(anderson.Restarts() == 0);
}