Skip to content

Baseline psalsa improvements - #118

Open
zeehio wants to merge 27 commits into
develfrom
claude/r-4-6-1-ubuntu-posit-x2ppdy
Open

Baseline psalsa improvements#118
zeehio wants to merge 27 commits into
develfrom
claude/r-4-6-1-ubuntu-posit-x2ppdy

Conversation

@zeehio

@zeehio zeehio commented Aug 20, 2026

Copy link
Copy Markdown
Member

No description provided.

claude and others added 27 commits August 19, 2026 15:14
Plots a few samples' original signal together with their estimated
baseline (from nmr_baseline_estimation()), so the baseline estimate
can be visually inspected against the signal.

- x = chemshift, y = intensity, colour = NMRExperiment
- linetype: solid for the signal, dashed for the baseline
- chemshift_range accepts a single length-2 vector, or a named list
  of them to facet several regions side by side (facet_grid with
  NMRExperiment in rows and, when more than one region is requested,
  regions in columns)
- validates the requested region(s) actually overlap the dataset's
  ppm axis, with a clear error instead of a downstream failure
facet_grid() has no built-in pagination, unlike facet_wrap(). Since
this function grids NMRExperiment (rows) against chemshift_range
regions (columns), nrow/ncol paginate each dimension independently:
nrow caps NMRExperiments per page, ncol caps regions per page. page
indexes through the resulting grid of pages, NMRExperiment pages
varying fastest. Defaults to showing everything on one page, matching
the previous behavior.
Drop the NMRExperiment facet dimension: every requested NMRExperiment
is now overlaid (by colour) within each region's facet_wrap() panel,
instead of getting its own facet_grid() row. nrow/ncol/page now
paginate the region facets directly, reusing the same snug-grid
default (1xn / 2x2 / 2x3 / 3x3) as nmr_baseline_threshold_plot().
Regions can have very different intensity ranges; sharing one y-axis
squashes smaller-signal regions flat next to larger ones. Free y (in
addition to the already-free x) makes each region's own signal/
baseline shape legible.
NMR peaks are Lorentzian-shaped, unlike gen_synthetic_1d()'s existing
"gaussian" and "gex" options. lorentz_peak_1d() is a height/fwhm-
parametrized wrapper around the package's existing canonical
lorentzian() (area_estimation.R), the same shape peaklist_fit_lorentzians()
already fits, so the synthetic tuning pool can now match what NMR
peak detection actually assumes.

Default peak_shape stays "gaussian" (first choice), so this is purely
additive for existing callers of tune_psalsa()/nmr_baseline_estimation().
AlpsNMR's synthetic tuning pool now matches NMR peaks' actual shape
by default. nmr_baseline_estimation() calls tune_psalsa() without
specifying peak_shape, so its automatic lambda/p/k tuning now also
defaults to lorentzian-shaped synthetic peaks instead of gaussian.
peak_area_errors_1d() used to sum the baseline-corrected signal over
each peak's own lo:hi window and compare to its true area -- valid
only when peaks don't overlap, since a neighbor's tail leaking into
that window contaminates the estimate. compute_peak_size_breaks_1d()
and tune_psalsa_params_1d()'s use_peak_area gate then had to restrict
to isolated peaks only (peak_is_isolated_1d()) to stay valid.

For Lorentzian peaks (now the default), that's nearly all of them:
measured 0% isolated on this project's real NMR data at realistic
peak density (vs. 11.2% for Gaussian, same placement), because a
Lorentzian's 1/x^2 tail crosses the 0.1%-height isolation threshold
~10x further from center than a Gaussian's at the same FWHM (measured
31.6x vs 3.1x FWHM). use_peak_area was always false as a result, so
tuning silently fell back to the cruder floor_rmse_1d objective for
any realistically dense synthetic pool -- exactly the regime real
crowded NMR spectra live in, so parameters tuned this way may not
transfer well to them.

Fix: since these are synthetic peaks, we know each one's own true
contribution at every point even where they overlap (gen_synthetic_1d()
now keeps enough per-peak info -- including gex's per-peak a/b, and the
peak_shape itself -- to reconstruct any single peak's curve on demand via
the new reconstruct_peak_1d()). peak_area_errors_1d() now splits the
corrected signal at each point in proportion to each peak's true relative
contribution there, instead of a hard window:

    w_i(x) = pk_i(x) / peaks_total(x)
    est_area_i = sum_x w_i(x) * corrected(x)

This reduces exactly to the old sum(corrected[lo:hi]) for a genuinely
isolated peak (verified: exact match), and gives every peak a valid,
uncontaminated area estimate regardless of overlap (verified: two
heavily-overlapping synthetic peaks under perfect baseline correction
recover their true areas independently, both at the same small
truncation-only error, summing to ~99.95% of the corrected signal in
their shared window). compute_peak_size_breaks_1d() and the
use_peak_area gate (now n_total >= 15, not n_iso >= 15) use the whole
peak population accordingly. Confirmed use_peak_area now correctly
flips true for lorentzian on this project's real data (was false).

This prioritizes correctness over speed: peak_area_errors_1d() now
reconstructs each peak's own curve inside the objective function,
adding cost on top of what profiling already found to be the dominant
step (phase 3, the Nelder-Mead search). Revisit if that cost matters
in practice.
reconstruct_peak_1d() and peak_area_errors_1d() are both @nord, so
[reconstruct_peak_1d()] and [peak_area_errors_1d()] never resolved to
anything (roxygen2 warned on every regeneration). Replaced with plain
text.
…sa.R

Both are @nord (diff2_penalty undocumented) or nonexistent (psalsa2d) so roxygen2 could not resolve the [] links; replaced with plain code text.
With no faceting to page through, pagination instead subsets which NMRExperiments (in the existing numeric-sorted order) land on the sample axis, samples_per_page at a time; requesting a page beyond the number available errors with the page count.
$data1r doesn't exist (the field is $data_1r), so mat silently became NULL, then NULL-minus-baseline lost its matrix dims via recycling. mat is already restricted to threshold_ind columns before the median3mad loop, so re-indexing by threshold_ind there was a second, redundant subscript that errored once mat regained the right dims. Both methods now verified end-to-end.
…plitting

On the full MTBLS242 dataset (134 samples, 47417 peaks), nmr_peak_clustering()
tried to allocate a single 47417x47417 dense distance matrix (~18GB) and OOM'd.

A valid complete-linkage cluster's ppm span must stay under max_dist_thresh_ppb
(how num_clusters is chosen), so in 1D, any two peaks sharing a final cluster
are necessarily within that same threshold of each other directly. Splitting
the peak list at every ppm gap exceeding that threshold and clustering each
resulting group independently (split_peaks_into_ppm_components() +
cluster_one_ppm_component()) therefore gives identical results to clustering
everything at once, while keeping each group's distance matrix down to the
size of one crowded region instead of the whole spectrum. On the real dataset
this drops peak memory from ~18GB (OOM) to ~4-9GB and completes in about a
minute; verified to reproduce the same partition as the old single-matrix
path on both a small worked example and a synthetic multi-region case.

Also:
- set_peak_distances_within_groups() no longer round-trips through a dense
  matrix to mask same-sample distances to Inf; it assigns directly into the
  triangular dist vector via index arithmetic, roughly halving peak memory
  for whichever group ends up largest.
- Fixed a latent bug in get_max_dist_ppb_for_num_clusters(): cutree() returns
  a plain vector instead of a matrix when only one candidate k is tested
  (which happens whenever a region's peaks all come from a single sample),
  and the subsequent matrix-style indexing broke on that. Pre-existing, but
  only surfaced once region splitting made single-sample regions common.

nmr_peak_clustering(peak2peak_dist=...) or nmr_peak_clustering(num_clusters=...)
still take the original single global hclust path unchanged, since neither
has a distance threshold to safely split by.
accept_inflections = FALSE rejects peaks whose apex isn't a genuine local
maximum of the raw signal within its own fitted window (ppm_infl_min -
ppm_infl_max) -- typically shoulder/inflection points detected on the flank
of a much larger neighbouring peak in crowded regions, rather than peaks of
their own. Defaults to TRUE (previous behaviour unchanged).

is_peak_local_max()/peak_is_local_max_1() check that the peak's own pos is
(within a 1% relative tolerance, to absorb fitting noise on a flat/rounded
apex) the window's maximum, and that intensity moves monotonically toward
it from both edges. The tolerance is scaled by abs(window max), not the raw
max, since a window can sit entirely below zero in flat/noisy baseline
regions -- an earlier version without the abs() inverted the tolerance
direction there, which a regression test now covers.

Verified against the real MTBLS242 dataset (134 samples, 70178 peaks):
accept_inflections = FALSE rejects 8829 shoulder peaks (12.6%), matching a
prior manual analysis of the same data to within a handful of peaks (the
remainder explained by peaklist_accept_peaks()'s pre-existing
excluded-region filter, which that manual analysis didn't apply). Runs in
~4.3s on the full dataset.
A fitted peak's area is height * pi * gamma, so it scales with linewidth as
much as with height -- area_min disproportionately rejects genuinely sharp,
narrow peaks (formate being the clearest example: gamma_ppb ~1-3 vs ~2-45
for a typical crowded-region peak, so its area comes out tiny despite a
tall, unambiguous peak). intensity_min/intensity_max filter on peak height
directly instead, so they don't carry that width bias.

Verified against the real MTBLS242 dataset: intensity_min = 500 keeps
167/175 (95.4%) of formate-apex detections, versus 3/175 (1.7%) with the
old area_min = 50 approach on the same peaks.

Added 3 new unit tests (including a no-op-by-default check) and fixed 3
existing tests whose fake peak_data fixtures lacked an intensity column,
which the new criteria now require. All 752 package tests pass.
Splits the input signal(s) into num_regions contiguous stretches and
characterizes peak density/fwhm separately per region (instead of one
pooled, signal-wide density/fwhm), so the synthetic tuning pool can
reproduce a spectrum's real region-to-region variation (e.g. a crowded
aliphatic region vs. a sparse downfield region) rather than blending it
into one uniform density. Opt-in via tune_psalsa(..., num_regions = N);
default (NULL) keeps the original signal-wide pooling untouched.

lambda/p/k are still tuned as single, signal-wide values either way --
this only changes how realistic the synthetic pool is.
Generalizes psalsa() to accept lambda/p/k as position-varying vectors
(one value per point) instead of only scalars, and adds
tune_psalsa_spatial() to build such profiles automatically: each of
num_regions regions is tuned independently against its own
region-matched synthetic pool, and the per-region optima are combined
into smooth lambda/p/k profiles via a natural cubic spline (in
log-space for lambda/k, logit-space for p, so the inverse transform
always lands back in a valid range regardless of spline over/undershoot
between knots).

diff2_penalty_weighted() builds the position-varying smoothing penalty
directly as t(D2) %*% diag(lambda) %*% D2 via a sqrt-then-crossprod
construction, preserving D2's pentadiagonal sparsity pattern exactly --
no memory blowup from going position-varying. psalsa_core()'s p/k are
now broadcast to the signal's own length before the reweighting step,
fixing a latent misalignment that a vector p/k would otherwise hit when
subsetting by the residual sign mask.

The existing scalar-lambda/p/k path is untouched (byte-identical),
tune_psalsa()'s default behaviour is unaffected, and
tune_psalsa_spatial() falls back to tune_psalsa() when fewer than 2
regions have enough signal to spline between.

Verified on the two MTBLS242 samples with the known baseline-bleed
issue: this cuts the median baseline-corrected offset in the affected
downfield window from ~362-386 down to ~85-90, versus only ~7-8%
improvement from region-aware synthetic data alone (tune_psalsa's
num_regions), confirming the bleed needed a position-varying penalty
rather than just more realistic tuning data.
The theta0 anchor added previously wasn't enough on its own: a region
with very few peaks in its own synthetic pool still collapsed to the
same "disable peak protection" degenerate optimum (lambda -> tiny, k ->
huge, p -> near p_max) already described in tune_psalsa_params_1d()'s
own comments, at the SAME regularization weight used for the
whole-spectrum search. Reproduced directly on a real MTBLS242 region
(20/20, 3 peaks/draw): lambda collapsed to ~400 vs. an anchor of
~2.8e7 even with the anchor in place, and the resulting position-
varying baseline interpolated the raw signal almost exactly in that
region for every sample, not just the ones the tuning was meant to fix.

Adds region_lambda_k_weight/region_p_weight (default 2/10, ~100x/~25x
the whole-spectrum defaults) so each region's own search is pulled much
more strongly toward the signal-wide anchor -- verified empirically
across independent synthetic draws to keep a sparse region's tuned
values close to the anchor instead of drifting to the escape hatch.

Also corrects course on the earlier real-data validation: the ad hoc
comparison window used for the earlier commits (8.20-8.60 ppm) was NOT
the vignette's actual nmr_baseline_threshold() reference region
(9.5-10 ppm). Redone against the correct window: the region-aware
synthetic data alone (tune_psalsa(num_regions = 20), already shipped)
turns out to already bring the two flagged samples' threshold from
18847/12447 down to ~921/835 -- essentially into the normal range
(420-1335 across other samples) by itself. With this regularization fix,
tune_psalsa_spatial() now lands at ~893/819 (matching, not dramatically
beating, the simpler region-aware tune) with all 18 tuned regions
staying sanely clustered (previously as many as 4-5 orders of magnitude
apart).
Addresses the sparse-region collapse at its root cause instead of only
fighting it with a stronger ridge: merge_sparse_regions_1d() greedily
merges ADJACENT regions (left-to-right, backward-merging a trailing
under-informed group into its predecessor -- but never force-merging a
genuinely empty trailing region into an already well-populated
neighbour, which would only dilute it) until each group has an
estimated peak count of at least min_peaks (default 15, matching
tune_psalsa_params_1d()'s own peak-tiering threshold). Wired into
tune_psalsa_region_params_1d()/tune_psalsa_spatial() via a new
min_peaks parameter.

This is complementary to, not a replacement for, the
region_lambda_k_weight/region_p_weight regularization added previously:
merging gives a region real data to constrain its own search, while the
regularization stays as a safety net for whatever a region still can't
resolve on its own (matching tune_psalsa_params_1d()'s own rationale for
why it regularizes even a well-populated whole-spectrum search).

Verified on the real MTBLS242 data: with the default num_regions = 20,
merging collapses the 20 raw bins down to 12 well-informed groups (the
previously-pathological sparse region gets absorbed into a wider,
properly-populated neighbour), and every tuned lambda now lands in a
sane 7.5e6-1.6e8 range (previously spanning 4-5 orders of magnitude).
The two originally-flagged samples' reference-window threshold (correct
9.5-10 ppm window) now lands at 777/713, comfortably inside the normal
range (764-890) seen across other samples.
Adds a num_regions argument (default NULL, unchanged behaviour): when
set, an "auto" lambda/p/k is tuned with tune_psalsa_spatial(num_regions
= num_regions) instead of the plain, signal-wide tune_psalsa(), giving
a position-varying baseline profile through the actual exported entry
point rather than only via internal AlpsNMR::: calls.

psalsa()'s own vector-lambda/p/k support (added earlier) means no
change was needed to how the tuned values get applied -- the existing
do.call(psalsa, ...) call already handles a vector or scalar lambda/p/k
transparently.

The psalsa_params attribute on data_1r_baseline now also records
num_regions alongside lambda/p/k, so a caller can tell whether a stored
baseline came from a scalar or position-varying tune; lambda/p/k stay
directly reusable via nmr_baseline_estimation(other_dataset, lambda =
psalsa_params$lambda, p = psalsa_params$p, k = psalsa_params$k) without
retuning, same as before.
…file

tune_psalsa_spatial()'s lambda/p/k profiles are as long as the tuned
spectrum, impractical to store verbatim (e.g. as a literal for reuse
without re-tuning); the much smaller region_params table (a few dozen
rows at most) plus noise_sd already determine them exactly via the same
spline interpolation tune_psalsa_spatial() itself uses. Adds
psalsa_region_params_to_profile(n, region_params, noise_sd) to rebuild
the full profiles from just those two small pieces -- verified to
reproduce a tune_psalsa_spatial() result's lambda/p/k exactly.

Also adds noise_sd to tune_psalsa_spatial()'s own return value (needed
to do this reconstruction at all; previously computed internally but
never returned).

Left unexported, matching the rest of the psalsa/tune_psalsa/
tune_psalsa_spatial family: this stays internal (AlpsNMR:::) machinery
for now rather than public API.
Fixes a real, previously-undiagnosed convergence bug: the reweighting
iteration depends on a hard threshold (d_geq <- d >= 0) deciding
whether each point sits above or below the current baseline estimate.
Near a flat/quiet region, many residuals sit close to zero, so a full
undamped step in the fitted curve can flip a large batch of points
across that threshold at once, which swings the next fitted curve
enough to flip a comparable batch back -- an oscillation that can
persist right up to maxit instead of settling, rather than slow but
monotonic convergence.

Verified directly on the MTBLS242 dataset: instrumented traces showed
the flip count dropping toward 0 (near-converged) then suddenly
jumping back into the thousands at iteration 23-24, for both the
already-tuned scalar parameters AND a normal control sample -- this
was not specific to any one sample or tuning approach.

damping < 1 under-relaxes the weight update (w <- damping * w_target +
(1 - damping) * w) instead of replacing w outright, directly shrinking
how much the fitted curve moves per iteration and therefore how many
points can cross the threshold in one step. damping = 1 (the default)
is an exact, byte-identical replacement of the original update -- zero
behavioural change unless a caller opts in.

Verified on the full 134-sample dataset with damping = 0.7, maxit =
100 (using the SAME already-tuned scalar lambda/p/k, no retuning):
every sample now converges within 100 iterations (min 38, median 54.5,
max 75), and the reference-window threshold that was previously an
18847 outlier for one sample drops to 1056, with every other sample's
threshold landing in a tight, sane 1000-2200 range (previously up to
18847) -- eliminating the anomaly that motivated this session's
region-based tuning investigation, without needing any of that
region-based machinery.
Covers: damping = 1 is byte-identical to omitting the argument (both
psalsa_core() directly and psalsa()/psalsa_one()); the damped update is
a proper convex combination of the target and previous weight;
damping = 0 keeps every smoother() call on the original unweighted w;
damping changes psalsa()'s output relative to the default; damping is
threaded through the matrix (multi-sample) path; and a flat,
borderline-noisy region (many residuals near zero, structurally the
scenario that can trigger the hard-threshold oscillation this fix
targets) converges reliably with damping = 0.5.
…uning

gen_synthetic_1d() draws peak heights from a lognormal centered on
0.35*A, but every per-region synthetic pool (both tune_psalsa()'s
num_regions path via gen_synthetic_1d_regions(), and
tune_psalsa_spatial()'s independent per-region tuning via
tune_psalsa_region_params_1d()) was calling it with A left at its
default of 1 for every region, regardless of that region's own real
peak height scale. Only density and fwhm varied by region; amplitude
never did, even though it's arguably the dimension that matters most
for k (the peak-height parameter) specifically.

Verified on real MTBLS242 data this was a real gap, not cosmetic: the
BCAA region's real peaks are ~10,000-700,000 in height, four orders of
magnitude above the ~100-700 height of small real features in the
"quiet" reference region -- yet both were being synthesized from the
exact same unit-amplitude peak-height distribution.

Adds a per-region `amplitude` (median detected-peak height / 0.35,
same convention as analyze_signal_1d()'s own A_peaks) to
analyze_signal_regions_1d()/pool_signal_stats_regions_1d(), propagated
through merge_sparse_regions_1d() (peak-count-weighted mean across
merged members) and into gen_synthetic_1d_regions()/
tune_psalsa_region_params_1d(). csnr is correspondingly rescaled per
region (csnr_region = noise_sd / amplitude) so the ABSOLUTE noise level
stays equal to the signal-wide noise_sd regardless of a region's own
amplitude -- real (thermal/electronic) noise doesn't scale with local
peak height the way peak heights themselves should.

Layered, fully backward-compatible fallback when amplitude can't be
determined: falls back to the signal-wide amplitude (noise_sd/csnr)
when available, and to the ORIGINAL pre-amplitude behaviour (no A
override, region_profile's own csnr as-is) when neither amplitude nor
noise_sd is available -- verified byte-identical to the old behaviour
in that case.
…rough nmr_baseline_estimation()

compute_psalsa_weights() replaces the hard d>=0 threshold with a
cubic-Hermite-interpolated central noise band [-s, s], avoiding the
p-floor bias the threshold method imposes on every above-baseline point
regardless of how close it is to the baseline. Guards against
c_noise > p, which makes the left-region exponential diverge instead of
saturating to w_max. weight_method = "smooth" only supports scalar p/k
(not tune_psalsa_spatial()'s position-varying profiles).

nmr_baseline_estimation() gains a damping = "auto" parameter mirroring
its existing maxit = "auto" pattern.

Validated against the full 134-sample MTBLS242 dataset: smooth weights
converge for every sample with a finite baseline, and the reference-window
(9.5-10 ppm) threshold drops from a median of 1299 (damped threshold
method) to 492, with a much tighter spread (425-609 vs 1210-2202 IQR).
…tes, in synthetic tuning

summarize_peaks_1d() now reports amplitude_q1/q2/q3 (peak height quartiles)
alongside the existing fwhm quartiles, so analyze_signal_1d() and
analyze_signal_regions_1d() extract identical features -- one set for the
whole signal vs. one set per region, both now including an amplitude
distribution instead of a single median/weighted-mean point value.

pool_signal_stats_1d()/pool_signal_stats_regions_1d() pool density the same
way: instead of collapsing straight to a single across-sample median,
density_q1/q2/q3 keeps the across-sample quartiles. amplitude_q1/q2/q3
pools via the same column-wise-median-across-samples treatment fwhm
already used. merge_sparse_regions_1d() recombines each quantile
independently (span-weighted for density, peak-count-weighted for
amplitude), generalizing its existing single-value formulas.

gen_synthetic_1d()/gen_synthetic_1d_regions() draw a fresh density value
from density_q1/q2/q3 on every call/every region (via the new
draw_from_quantiles_1d() -- a distribution-free piecewise-linear quantile
function through the 3 known points), so a batch of synthetic draws
reproduces real sample-to-sample density variation instead of every draw
sharing one fixed value. Each individual peak likewise draws its own height
from amplitude_q1/q2/q3, rather than every peak in a draw sharing one
lognormal centered on a single A. Defaults preserve the old deterministic/
single-A behaviour when callers don't supply real quantiles.

Updated the existing hand-built test fixtures across
test-psalsa_region_amplitude.R, test-psalsa_region_anchor.R,
test-psalsa_spatial.R and test-psalsa_tune_regions.R to the new
density_q1/q2/q3 and amplitude_q1/q2/q3 schema.
…or scoring

cap_density existed because a hard lo:hi window can't validly attribute
area wherever peaks overlap. peak_area_errors_1d()'s fractional
attribution (w_i(x) = pk_i(x) / peaks_total(x), using each peak's own
known ground-truth shape) scores overlapping peaks correctly too, so
that reason no longer applies.

Verified on the real MTBLS242 dataset: with cap_density = TRUE (the old
default), the peak-spacing cap bound in every region tried, capping the
peak count to 3-7x below what the analyzed density actually implied --
silently overriding the density_q1/q2/q3 draw regardless of its value
(every synthetic draw got the same, cap-determined peak count). With the
default now FALSE, peak counts genuinely vary draw to draw, tracking the
analyzed density as intended.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants