The partials and fulls scaling loops are alternating least squares that converge linearly: over the rc176 battery's 2527 loops a round shrinks the step by a median 0.86 (partials) and 0.93 (fulls), up to 0.98, so the 1e-3 step tolerance stopped them 0.6% / 1.4% (median) and up to 17% (fulls p90) short of their fixed point, and 313 of them were stopped as "stalled" when they were only slow - a stalled fulls loop also skipped ShrinkToRestrained. Each round is now accelerated in log G by type-II Anderson mixing (Walker & Ni 2011), depth 5, each frame weighted by its observation count as in the step test; frames below the credible floor take the plain round's scale. The history is dropped when the weighted residual grows or the set of fitted frames changes (new AndersonMixing class, unit-tested on a synthetic slow ALS loop). The mixed scales are gauge-pinned again before the rescale, and the stop test is unchanged: it reads the round's residual F(x) - x, so "stalled" now means a loop that does not settle under acceleration either. Measured (CPU, -N 8): 4nwv 311 -> 82 iterations over 9 loops, 9v16 76 iterations over 11 loops, the same counts as the env-gated prototype on tail-solver; no history restart fired on either. Depth 3/5/8 give 92/91/90 iterations on 4nwv. A restart on a change of the fulls loop's cross-validated lambda was tried and gave nothing (83 vs 82). Same space group and merging statistics on 4nwv and 9v16. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
116 lines
5.3 KiB
C++
116 lines
5.3 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_RestartsWhenTheResidualGrows", "[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.Restarts() == 0);
|
|
CHECK(anderson.Next({0.0}, {2.0}, w)[0] == 2.0); // residual grew: history dropped, plain step
|
|
CHECK(anderson.Restarts() == 1);
|
|
}
|