Rotation merge: outlier band's multiplicative spread measured, not taken from b

The multiplicative outlier band (c8de5f8d6) took its width from the error
model's b. b is a Gaussian width in I and grows with whatever tail the fulls
have: on a low-ISa hexagonal small-molecule set with a population of fulls
lost to near zero, b = 0.54 against a core ln-spread of 0.25, and exp(6 b) = 25
let the high outliers of the weak reflections through (SHELXL R1 0.191 ->
0.333 there, 0.111 -> 0.124 on its 25 keV sweep).

The spread is now measured on the merge's own fulls: the weighted median of
|ln(I / median)|, weighted by (<I>/sigma_counting)^2 so the fulls whose ratio
counting noise does not blur carry it; b remains the fallback where nothing
can be measured.

Battery (29 sets: 13 small-molecule, 16 protein), SHELXL R1 against the
median fix alone: the strongly absorbing cubic set 0.116 -> 0.0998 (b-band
0.103); the low-ISa hexagonal set 0.191 -> 0.174 at 20 keV, 0.111 -> 0.101 at
25 keV; organics within +-0.0003 or better (cytidine 0.0617 -> 0.0613,
lalanine 0.0541 -> 0.0533). Proteins unchanged except two low-ISa sets whose
CC1/2 cutoff moves: 6yqf 3.32 -> 2.89 A (placement R-free at a fixed 3.32 A
0.4329 -> 0.4326, at 2.89 A 0.4642 -> 0.4630), and the split myoglobin set
1.77 -> 1.94 A (low-resolution R_meas 33.0% -> 27.3%). Private subset: two
sets' R_meas lower, the rest unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
This commit is contained in:
2026-10-04 21:03:11 +02:00
co-authored by Claude Opus 5.5
parent a012a0b5cb
commit 4ec197c68c
4 changed files with 48 additions and 11 deletions
+4 -3
View File
@@ -13,9 +13,10 @@
// linear band reaches far below the median and only a little above it. On a strongly absorbing crystal
// (b = 0.34) every full the test removed read 2-7x its median - the least absorbed fulls, the ones
// nearest the true intensity - and none read low, so the merged intensities were cut from above only.
// Here the multiplicative part of the band is exp(+-n*b) about the expected intensity, which is the
// linear band to first order in n*b, and the counting part stays linear.
// up = exp(n*b) - 1 and down = 1 - exp(-n*b) are per-merge constants, taken on the host and handed to
// Here the multiplicative part of the band is exp(+-n*s) about the expected intensity, s the spread of
// ln(I / median) measured on the merge's own fulls (MergeAndStats), and the counting part stays linear.
// With s = b it is the linear band to first order in n*b.
// up = exp(n*s) - 1 and down = 1 - exp(-n*s) are per-merge constants, taken on the host and handed to
// the device, so that both sides compute them from the same library call.
#include <cmath>
@@ -4525,6 +4525,7 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
// ---- Error model: fit dev2 = a*sigma^2 + b^2*<I>^2 from symmetry-equivalent scatter. ----
std::vector<double> em_mean(n_groups, NAN);
std::vector<float> reject_median(n_groups, NAN);
double reject_log_sd = NAN; // the measured multiplicative spread the outlier band uses (OutlierBand.h)
std::vector<float> reject_var_add; // per group: the shell's measured share of dI^2/4
double error_model_a = 1.0, error_model_b = 0.0;
bool error_model_active = false;
@@ -4751,6 +4752,37 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
for (int g = 0; g < n_groups; ++g)
reject_median[g] = cnt[g] >= 3 ? set_median[g]
: !pair_needed.empty() ? set_median[n_groups + pair_of_group[g]] : NAN;
// The spread of ln(I / median) the band's multiplicative part is drawn from: its weighted median
// absolute value, the weight (<I>/sigma_counting)^2 so the fulls whose ratio counting noise does
// not blur carry it. Measured here rather than taken from the error model's b, which is a
// Gaussian width in I and grows with whatever tail the fulls have: a run with a population of
// fulls lost to near zero fits b = 0.54 against a core spread of 0.25, and exp(6 b) then let
// every high outlier of the weak reflections through.
std::vector<std::pair<double, double>> lv;
for (int i = 0; i < n_full; ++i) {
const int g = mf.group[i];
if (g < 0 || !std::isfinite(reject_median[g]) || !(reject_median[g] > 0.0f)
|| !std::isfinite(em_mean[g]) || !(em_mean[g] > 0.0))
continue;
const double I_corr = static_cast<double>(mf.I[i]) * mf.corr[i];
const double sc = static_cast<double>(mf.sigma[i]) * mf.corr[i];
const double var = counting_variance(fulls[i], em_mean[g], sc * sc);
if (!(I_corr > 0.0) || !(var > 0.0)) continue;
lv.emplace_back(std::fabs(std::log(I_corr / reject_median[g])), em_mean[g] * em_mean[g] / var);
}
if (!lv.empty()) {
std::sort(lv.begin(), lv.end());
double total = 0.0;
for (const auto &[v, w] : lv) total += w;
double run = 0.0;
size_t j = 0;
for (; j + 1 < lv.size(); ++j) {
run += lv[j].second;
if (run >= 0.5 * total) break;
}
reject_log_sd = 1.4826 * lv[j].first;
}
}
fit_error_model(samples);
}
@@ -4940,11 +4972,16 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
for (size_t j = 0; j < wobs.size(); ++j)
if (wilson.rejected[j]) rejected_obs[wilson_full[j]] = 1;
}
// The multiplicative part of the outlier band (OutlierBand.h): the measured spread, or the error
// model's b where nothing could be measured.
const double mult_sd = std::isfinite(reject_log_sd) ? reject_log_sd : error_model_b;
const double reject_up = std::expm1(reject_nsigma * mult_sd);
const double reject_down = -std::expm1(-reject_nsigma * mult_sd);
bool did_gpu_acc = false;
#ifdef JFJOCH_USE_CUDA
if (use_gpu_merge) {
gpu_->MergeAccum(error_model_a, error_model_b, error_model_active,
reject_outliers, reject_nsigma, reject_median.data(),
reject_outliers, reject_nsigma, mult_sd, reject_median.data(),
reject_var_add.empty() ? nullptr : reject_var_add.data(), merge_half.data(),
frame_cc_factor.data(), rejected_obs.data());
// Downloaded a slice of groups at a time and unpacked straight into acc, so the host never
@@ -4982,9 +5019,6 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
did_gpu_acc = true;
}
#endif
// The multiplicative part of the outlier band (OutlierBand.h), from the error model as it stands.
const double reject_up = std::expm1(reject_nsigma * error_model_b);
const double reject_down = -std::expm1(-reject_nsigma * error_model_b);
if (!did_gpu_acc)
for (const auto &o : fulls) {
if (!usable_merge(o)) continue;
@@ -1197,7 +1197,8 @@ void RotationScaleMergeGPU::MergeEmSamples(bool for_search, double min_partialit
}
void RotationScaleMergeGPU::MergeAccum(double error_model_a, double error_model_b, bool error_model_active,
bool reject_outliers, double reject_nsigma, const float *reject_median,
bool reject_outliers, double reject_nsigma, double reject_mult_sd,
const float *reject_median,
const float *reject_var_add,
const uint8_t *half, const double *frame_cc_factor,
uint8_t *rejected_obs) {
@@ -1225,8 +1226,8 @@ void RotationScaleMergeGPU::MergeAccum(double error_model_a, double error_model_
p.error_model_a = error_model_a; p.error_model_b = error_model_b;
p.error_model_active = error_model_active ? 1 : 0;
p.reject_outliers = reject_outliers ? 1 : 0; p.reject_nsigma = reject_nsigma;
p.reject_up = std::expm1(reject_nsigma * error_model_b);
p.reject_down = -std::expm1(-reject_nsigma * error_model_b);
p.reject_up = std::expm1(reject_nsigma * reject_mult_sd);
p.reject_down = -std::expm1(-reject_nsigma * reject_mult_sd);
p.reject_median = d.reject_median.get();
p.reject_var_add = d.reject_var_add.get();
p.I = d.f_I.get(); p.sigma = d.f_sigma.get(); p.corr = d.f_corr.get(); p.partiality = d.f_partiality.get();
@@ -105,8 +105,9 @@ public:
// half-set weights multiplied by it. Requires MergeEmSamples first (em_mean resident).
// reject_var_add (n_groups) widens the pooled cut by the shell's own measured Bijvoet
// variance; null leaves the plain n-sigma test.
// reject_mult_sd is the multiplicative spread of the outlier band (OutlierBand.h).
void MergeAccum(double error_model_a, double error_model_b, bool error_model_active,
bool reject_outliers, double reject_nsigma, const float *reject_median,
bool reject_outliers, double reject_nsigma, double reject_mult_sd, const float *reject_median,
const float *reject_var_add,
const uint8_t *half, const double *frame_cc_factor, uint8_t *rejected_obs);
// Download groups [g0, g0 + n) of what MergeAccum left on the device; every output has length n.