Skip to content

Commit d8ae11d

Browse files
committed
flex with mask note for Afan
1 parent b9a34e3 commit d8ae11d

1 file changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# flex_pca: sampling-distance control, reconstruction backends, and the envelope mask as solve support
2+
3+
## Status
4+
5+
**Planning only (2026-09-10, revised after review). No code is proposed for immediate implementation.**
6+
7+
Scope, as decided:
8+
9+
- `flex_pca` contains no masking logic. The mask is an input: a real-space
10+
`[0,1]` volume with a smooth falloff, supplied through `pcg_mskfile`. The
11+
program resamples and validates it, nothing more.
12+
- Before anything else, the fixed `box_crop=64` working box is replaced by a
13+
sampling-distance parameter that controls down-sampling, in the way
14+
`refine3D` and `abinitio3D` do it.
15+
- Then `rec_backend=gridding` (the current implementation) and
16+
`rec_backend=pcg` (new) are supported in the same pattern as those
17+
workflows, reusing `reconstructor_pcg` wherever it applies, first with the
18+
spherical support that is used today.
19+
- Only then does `pcg_mskfile` give the envelope-constrained analysis, and it
20+
is available on the PCG backend only, as it is everywhere else
21+
(`parameters_phases.f90:897`).
22+
23+
Code references are to `0eee68726`.
24+
25+
## 1. Order of work
26+
27+
```text
28+
step 0 smpd_target-driven working box (replaces box_crop=64)
29+
step 1 rec_backend=gridding|pcg in flex_pca, spherical support, reconstructor_pcg reused
30+
step 2 pcg_mskfile -> soft envelope as the solve support (PCG only)
31+
```
32+
33+
Each step is gated on its own and none needs the next; step 0 is a
34+
prerequisite for both others because the box decides the memory and the
35+
resolution of everything below it.
36+
37+
## 2. Step 0: a sampling distance instead of a fixed box
38+
39+
### 2.1 Today
40+
41+
`exec_flex_pca` sets `box_crop=64` unless given
42+
(`simple_commanders_flex_pca.f90:43`), then `derive_flex_pca_band` derives
43+
`lp = 2.5 * smpd_crop` (`COV_LP_OVER_NYQUIST`) and `box_rec = box`
44+
(lines 133-170); `parameters` derives
45+
`smpd_crop = box/box_crop * smpd` (`parameters_phases.f90:326`). The
46+
particle cache (`ptcl_cache_ensure`), the projected-model image prep, the
47+
basis reconstructors, the `vol1` mean and the probe-basis volumes
48+
(`read_and_crop` at `box_crop`/`smpd_crop`) all follow `box_crop`. The
49+
covariance is therefore fitted at whatever sampling `box/64` happens to be:
50+
~3.5 A on a 200 A particle in a 256 box at 0.9 A, ~6 A in a 400 box -- the
51+
resolution of the analysis is an accident of the box.
52+
53+
### 2.2 Proposed
54+
55+
Follow `refine3D` (`simple_commanders_refine3D.f90:187-215`): a target
56+
sampling distance, `autoscale(box, smpd, smpd_target, box_crop, smpd_crop,
57+
scale, minbox)` from `simple_magic_boxes.f90:104` (magic box sizes, never
58+
finer than the data, floor at 64), and `box_crop`/`smpd_crop` set on the
59+
command line for the workers. Points specific to `flex_pca`:
60+
61+
- **Default.** Helix-level detail needs the working Nyquist at or below
62+
~4.5 A (alpha-helical pitch 5.4 A, strand separation 4.8 A), i.e.
63+
`smpd_target <= 2.25 A`. A default of **2.2 A** is proposed; the exact
64+
value is an open question (§6), and `refine3D`'s 1.3 A is too fine for
65+
the covariance stage at present accumulator sizes.
66+
- **`lp` and `smpd_target` are one decision.** Today `lp` is derived from
67+
the box; with a sampling distance both directions are possible and only
68+
one should be primary. Proposed: `smpd_target` is primary and
69+
`lp = COV_LP_OVER_NYQUIST * smpd_crop` stays derived; an explicit `lp`
70+
instead sets `smpd_target = lp / COV_LP_OVER_NYQUIST` before
71+
`autoscale`, so a user asking for a 6 A band gets a 2.4 A lattice rather
72+
than a 6 A band on a 3.5 A lattice. An explicit `box_crop` remains an
73+
override for tests.
74+
- **`box_rec` stays the native box.** The delivered state maps are not
75+
capped at the covariance Nyquist; unchanged.
76+
- **Memory scales as `box_crop^3`.** The coupled M-step accumulators
77+
(`rho_cross_exp`, `npairs x` expanded grid) and, on the PCG backend, the
78+
pair kernels (§3.3) grow with the cube of the box. Going from 64 to 96
79+
or 128 is x3.4 or x8. The existing `COV_ATHR_BUDGET` /
80+
`cov_dim_budget` machinery budgets the reduced solve's dimension; the
81+
same budgeting has to cover the accumulators, and the log should print
82+
the footprint at start-up.
83+
- **Provenance.** The embedding cache (`infile` resume) is tied to the
84+
particle selection; it must also be tied to `box_crop`/`smpd_crop`, or a
85+
cache built at one sampling will be resumed at another. Same for the
86+
probe-basis files and `flex_pca_probe.txt`.
87+
88+
## 3. Step 1: `rec_backend` in flex_pca
89+
90+
### 3.1 Which reconstructions
91+
92+
`flex_pca` reconstructs in two places (the mean is an input, `vol1`;
93+
`COV_MEAN_FROM_DATA=.false.`):
94+
95+
1. **State maps** (`simple_flex_pca_rec3D.f90`): kernel-weighted
96+
reconstruction per state and half at `box_rec`, currently gridding with
97+
a shell-relative density floor (`floor_rho`), `zero_background` and
98+
`mask3D_soft` at the broadest sphere in the box (lines 495-502). This is
99+
the direct analogue of `reconstruct3D`.
100+
2. **The M-step basis solve** (`simple_flex_pca_em_iter.f90:2394-2432`):
101+
`solve_coupled_basis_exp` (`simple_flex_projected_latent_model.f90:446`)
102+
divides the `k` right-hand sides per Fourier voxel by the `k x k`
103+
gridding density `rho_cross_exp`, then inverse-KB envelope, half-set
104+
FSC Wiener `2F/(1+F)`, band-limit, `mask3D_soft(msk_crop)`.
105+
106+
Both get the backend switch; the gridding paths stay as they are.
107+
108+
### 3.2 Pattern
109+
110+
`rec_backend` is already a validated global parameter
111+
(`parameters_phases.f90:779-782`), `pcg_mskfile` already requires it to be
112+
`pcg` (line 897), and the PCG strategy's preconditions apply unchanged
113+
(`pcgop=kernel`, `mskdiam` set, `projrec=no`;
114+
`simple_rec3D_pcg_strategy.f90:613-632`). `flex_pca` has its own
115+
master/worker stage protocol (`simple_flex_pca_distr.f90`,
116+
`PCA_STAGE_STATES`), so the selector lives in the flex layer rather than in
117+
`create_rec3D_strategy`: one switch on `params%rec_backend` at the two
118+
sites above, and the `>>> ... REC3D EXECUTION (gridding|kernel PCG)` style
119+
log line so a run says which backend it used.
120+
121+
### 3.3 States on `reconstructor_pcg`
122+
123+
Reused wholesale; the kernel weights enter through the noise model, not
124+
through a new weight array.
125+
126+
**Mechanism.** In `prepare_fused_planes`
127+
(`simple_reconstructor_pcg.f90:2158-2222`) each particle contributes the
128+
right-hand-side plane `weighted(h,k) = conj(CTF.shift) y(h,k) / sig2(shell,i)`
129+
and the density plane `absT2(h,k) = CTF^2 / sig2(shell,i)`; `absT2_plane`
130+
(2087-2145) builds the same density for the kernel path. Everything else in
131+
the solve -- the preconditioner `1/(rho+floor)`, the Gram kernel `Khat`, the
132+
data scale behind a relative `lambda` and the `P_tau` scale, and the raw
133+
accumulator artifacts the workers write -- derives from those two
134+
accumulators (`finalize_density_accum`, `finalize_khat`,
135+
`update_lambda_from_density`). The weighted least-squares objective
136+
`sum_i w_i ||Sigma_i^{-1/2}(A_i x - y_i)||^2` is therefore obtained exactly
137+
by passing `prep_particles` the array
138+
`sig2(0:R,i) = sigma2_noise(:,pinds(i)) / w_i(s)`: the weight multiplies the
139+
RHS term and `|T_i|^2` identically, as the normal equations require, and
140+
propagates to the preconditioner, the kernel, the ridge and the raw
141+
artifacts without touching any of them. The rec3D PCG strategy already
142+
builds that array per particle from the sigma store
143+
(`simple_rec3D_pcg_strategy.f90:712-718`; under `cc` it is a unit array and
144+
the division applies just the same), so the change is confined to the flex
145+
caller: one division per particle and shell before `prep_particles`, per
146+
state and per half, the even/odd split coming from the disjoint halves of
147+
the weight table as `mask_state_weights_by_half` does today.
148+
149+
**Details.**
150+
151+
- Particles with `w_i(s) = 0` are removed from `selection`/`pinds` for
152+
that state rather than given an infinite sigma; they contribute nothing
153+
and dropping them shrinks the particle pass, which matters for a state
154+
holding a small fraction of the data. A floor on `w` (of order 1e-3)
155+
keeps `sig2` finite in single precision; particles below it are dropped
156+
too.
157+
- The ridge is set in relative mode (`set_lambda_relative`), not the
158+
absolute `PCG_LAMBDA` the refinement strategy passes to `new`: with
159+
weights in `[0,1]` the effective particle count of a sparse state can be
160+
a small fraction of `N`, and an absolute 1e-3 would be a materially
161+
stronger prior on low-occupancy states than on populated ones, whereas
162+
the relative form scales with the weighted `D` and treats every state
163+
alike.
164+
- The shell-relative preconditioner floor `RHO_FLOOR_FRAC` scales with the
165+
weights automatically; it takes over the role of flex's `floor_rho`
166+
without biasing the estimate.
167+
- Rejected alternative: an explicit per-particle weight array inside the
168+
reconstructor, multiplied in at the two plane routines. Same numbers,
169+
more code, a second scaling path beside sigma; `add_raw_accum_weighted`
170+
is no help since it weights whole artifacts (trailing-reconstruction
171+
continuation), not particles. It would only be worth it if the solver
172+
sidecars had to report unweighted sigma statistics, and the data scale
173+
of the weighted problem is the one that describes the solve.
174+
- The sigma route works for the states because the weight is a scalar per
175+
particle. It does not carry over to the M-step, where the per-particle
176+
weight is the `k x k` matrix `E[z_i z_i']` and the pair-weighted kernels
177+
need the explicit generalization of `accumulate_absT2` (§3.4).
178+
179+
**Flow.** Per state and half: accumulate at `box_rec`, workers
180+
`write_raw_accum` (keyed by state and half), master `add_raw_accum`, base
181+
solve cold with at least `FINAL_PCG_MAXITS_FLOOR` iterations, spherical
182+
support via `set_mask` at the `msk` radius, shipped map `window * u`,
183+
provenance sidecar. This replaces `floor_rho`, `zero_background` and the
184+
broadest-sphere mask. The merge gate
185+
(`simple_flex_pca_merge.f90:392-417`) then compares support-constrained
186+
states; its 0.98 similarity threshold was calibrated on sphere-masked
187+
gridding maps and is re-calibrated on the synthetic fixture.
188+
189+
### 3.4 The M-step on the PCG operator
190+
191+
The coupled normal system has `(q,r)` blocks
192+
`H_qr = sum_i M_i(q,r) a_i^2 P_i^H C_i^2 P_i / sigma^2`, with
193+
`M_i = E[z_i z_i']`: the single-volume operator with a scalar weight per
194+
particle and pair. The present per-voxel `k x k` solve on the gridding
195+
density is the block-Jacobi preconditioner of that system, so the PCG
196+
version keeps `solve_coupled_basis_exp` in exactly that role and adds:
197+
198+
- pair-weighted Gram kernels `Khat_qr`, `k(k+1)/2` of them, from the same
199+
particle pass that accumulates the right-hand sides (the weighted
200+
generalization of `accumulate_absT2`); real and symmetric, packed
201+
half-grid, ~4 MB each at box 64, x3.4 at 96, x8 at 128 -- budgeted with
202+
the pair count (§2.2);
203+
- the support `P (x) I` on the hard domain `window > 0`, deapodization
204+
inside the operator (replacing the post-solve `mstep_gridcorr`
205+
multiply), shipped basis `window * U`;
206+
- CG to a relative-residual tolerance, not a fixed count: at these box
207+
sizes an iteration is `2 x npairs` small FFTs and costs nothing next to
208+
the accumulation, and the EM fixed point should be the least-squares
209+
fixed point rather than an iteration-count artefact.
210+
211+
Even and odd bases are solved separately on the same support, as now, and
212+
the per-component half-set FSC Wiener filter and band-limit stay
213+
post-solve in the first version, followed by a re-window (the filter leaks
214+
a little across the support edge). Moving the per-component FSC precision
215+
inside the operator as `P_tau` is a possible second version, not part of
216+
step 1. The marginal-likelihood diagnostic `-2logL/N` logged per probe
217+
iteration (`em_iter.f90:2367-2393`) is the arbiter between the backends,
218+
since a half-set FSC under a common window is optimistic on both.
219+
220+
### 3.5 Unchanged
221+
222+
E-step, embedding, kernel weights, targets and the merge logic. The
223+
initial basis (`simple_flex_pca_em_basis.f90:88`) and the probe-basis load
224+
(`simple_flex_pca_em_fit.f90:505`) multiply by the same window the solve
225+
uses -- with the spherical support that is the sphere they use today, so
226+
step 1 changes nothing there.
227+
228+
## 4. Step 2: `pcg_mskfile` as the support
229+
230+
### 4.1 Input contract
231+
232+
A real-space `[0,1]` volume with a smooth falloff at the project's native
233+
box and sampling (`automask3D_stateNN.mrc` from a `refine3D_auto`
234+
`automsk=yes` run, or any user mask). `flex_pca` does not threshold,
235+
dilate, soften or otherwise edit it. It validates: cubic, box equal to the
236+
project box, values in `[0,1]` after resampling, non-empty. Whether the
237+
envelope is generous enough to contain the moving parts is the user's
238+
responsibility when preparing the mask; the dilation and edge controls
239+
live in `automask3D` and the refinement policies, not here.
240+
241+
### 4.2 Resampling without Fourier-crop artefacts
242+
243+
`read_and_crop` (`simple_image_geom.f90:673`) is an `fft`,
244+
`clip_inplace`, `ifft`: a brick-wall low-pass. On a mask that is
245+
harmless when the falloff is wide compared with the target voxel, and
246+
ringing when it is not: overshoot above 1, undershoot below 0, and --
247+
the case that matters for a hard support -- positive ripples *outside* the
248+
mask that would extend the domain `window > 0` into solvent. The mask is
249+
needed on two lattices, `box_crop`/`smpd_crop` for the basis and
250+
`box_rec`/`smpd_rec` for the states, and the same routine serves both:
251+
252+
1. Fourier crop to the target box (`read_and_crop` as is).
253+
2. Record `min` and `max` before clamping and the fraction of voxels
254+
outside `[0,1]`; these are the ringing diagnostic and go to the log.
255+
3. Clamp to `[0,1]`.
256+
4. Floor: values below a small threshold are set to exactly 0 so the
257+
hard domain is the intended envelope and not its ripple; the threshold
258+
is a constant of the mask contract (the PCG solver already treats
259+
`window < PCG_SUPPORT_DIV_MIN = 0.1` as outside for its warm-start
260+
division, `simple_reconstructor_pcg.f90:597-606`; the floor here should
261+
be no larger than that and is a decision to make on the fixture).
262+
5. Report occupancy (fraction of the box, and of the `msk` sphere) on the
263+
native and the target lattice; a large change is the sign that the
264+
falloff was too narrow for the target sampling.
265+
266+
The falloff width that keeps the Fourier route clean is a few target
267+
voxels; at 2.2 A that is ~7-10 A, which the 7.5 A `ENVMSKWIDTH_A_MIN`
268+
dilation with its cosine skirt already satisfies for masks that come from
269+
`automask3D`. If the diagnostics of step 2 show ringing on user masks in
270+
practice, the fallback is real-space (trilinear) down-sampling of the mask,
271+
which cannot ring and only blurs the edge slightly; it should not be the
272+
default because the Fourier route is what every other volume in the run
273+
goes through.
274+
275+
### 4.3 Where the support enters
276+
277+
Exactly where the sphere enters in step 1, with `set_mask_volume` in place
278+
of `set_mask`: the state solves at `box_rec`, the coupled M-step at
279+
`box_crop`, the initial-basis and probe-basis windows, and the merge gate
280+
(which then compares windowed states and needs no mask of its own). The
281+
`vol1` mean from a PCG `automsk=yes` refinement is already windowed by the
282+
density envelope; passing that same envelope file as `pcg_mskfile` makes
283+
mean and basis share one support, which is the recommended use. On the
284+
gridding backend `pcg_mskfile` is rejected by the existing validation, so
285+
the envelope-constrained analysis is a PCG-only mode, as in `refine3D`.
286+
287+
## 5. Validation
288+
289+
- **Step 0:** the same run at `smpd_target` 3.5 / 2.6 / 2.2 A on the
290+
synthetic fixture (`production/tests/simple_test_flex_pca.f90`) and on
291+
one real dataset; ground-truth basis capture, `-2logL/N`, wall time and
292+
peak RSS per setting. The default is the coarsest setting on the capture
293+
plateau.
294+
- **Step 1:** gridding vs PCG at the same sampling and spherical support:
295+
per-state unmasked FSC, basis capture, `-2logL/N`, reproducible
296+
dimension count and the principal-angle history (the 0.97 / 0.02
297+
stopping constants were tuned on gridding bases and may move), the
298+
merge-gate threshold, and a `refine3D_states` run seeded from each set of
299+
states.
300+
- **Step 2:** the resampling diagnostics of §4.2 on `automask3D` masks and
301+
on deliberately sharp masks; sphere vs envelope support on the fixture
302+
and on a membrane protein (PfCRT), watching the leading-axis background
303+
ratio that the `DEFLATE_BG` comment records at 27x on real data and the
304+
micelle's share of the leading components.
305+
306+
## 6. Open questions
307+
308+
- Default `smpd_target`: 2.2 A is proposed on the helix argument; the
309+
fixture sweep may argue for a coarser default with the fine value as an
310+
option.
311+
- Is `smpd_target` primary and `lp` derived (proposed), or the reverse?
312+
- The floor below which a resampled mask value counts as outside the
313+
hard support (§4.2 item 4).
314+
- Hard support (`window > 0`) at 2.2 A sampling, or the soft `P = window`
315+
alternative that `install_support` rejects for refinement? The
316+
refinement argument (a solver-state-dependent mixture of `P*u` and `u`)
317+
applies here too, so hard is the default proposal.
318+
- Whether the per-component Wiener step ever moves inside the operator
319+
as `P_tau` (a second version of §3.4, not part of the plan).

0 commit comments

Comments
 (0)