Skip to content

Linear (Identity) baseline arm for the scv2-spike pipeline (SI Figure S10) #293

Description

@jaredgalloway

Linear (Identity) baseline arm for the scv2-spike pipeline (SI Figure S10)

📌 Standalone — descoped from EPIC #290 on 2026-08-17

This work was originally Phase 3 of EPIC #290. It will not be
completed as part of that epic: the epic is closing out its remaining
phases (#296 figure assembly, #297 written handoff) without the linear
arm, and this issue carries the work forward on its own schedule.

Nothing about the spec below was weakened by the descope. The body is
the same fully-specified, skeptic-reviewed spec that was written for the
phase, preserved verbatim apart from the epic-coupling edits listed in §0.
It remains ready to be worked on — it is not a stub.

Blocks nothing #296 and #297 no longer wait on this issue
Depends on the (tol, maxiter) pair chosen by #291 and the cached spike fit from #292both landed, so this is unblocked today
Deliverable SI Figure S10 (shrinkage_analysis_linear_models) plus the linear arm's rule, CSVs, tests and docs page

0. What the descope changed

Only the epic-coupling statements were edited. The scientific content, the
design decisions (§2), the deliverables (§3), the validation gates (§4) and
the open questions (§4c) are untouched.

Was Now
"Phase 7 was told to surface [the S10 erratum]" §1a's refutation stands on its own; the EPIC's handoff phase (#297) has been told to drop the erratum claim rather than inherit it
Figure name kept "so Phase 6's number-diff can match it" kept so any later number-diff can match it — #296 now audits the epic's figures without S10
"Figure assembly … is Phase 6 (#296)" still out of scope here; #296 ships without S10 and this issue owns S10 end-to-end, including its own number-diff against si.tex
Implicit: S10 lands before the manuscript handoff It does not. #297's handoff must state that S10 was not regenerated. Landing this issue later means a follow-up note to the manuscript, not a revision of #297

⚠️ The one substantive consequence. With S10 descoped, SI Figure S10 is
now a carried-over-unchanged figure in the manuscript rather than a
regenerated one — while every other figure it would be read alongside was
regenerated under the new (tol, maxiter) and the new λ = 8.0e-05. Whoever
picks this up must re-check §1a's refutation against the current fit, not
against the state of the world when the spec was written.


⚠️ READ FIRST — this phase is an experiment, not a reproduction

Figure S10's published caption asserts that "the linear models result in a
higher (worse) loss on the training and validation data."
This phase does
not assume that claim and must not be designed to produce it.
It fits the
linear arm, measures the losses, and reports what they are — including the
outcome where the caption is not supported.

A skeptic review of an earlier draft of this spec found three independent
mechanisms
by which a careless design would have produced "linear is worse"
with no involvement from global epistasis at all. They are not hypothetical;
each is quantified in §D1. They are the reason the α and regularization
settings are treated below as open questions the run answers empirically,
rather than as constants chosen up front:

# Artifact mechanism Why it would fake the result
A1 beta_clip_range binding At low α the required |β| exceeds the clip, truncating exactly the large-effect mutations and inflating linear loss
A2 Effective-L2 mis-scaling On the identified scale the penalty is l2reg/α², so α alone changes shrinkage by orders of magnitude
A3 Noise floor The sigmoid's validation minimum is ~0.7% deep while between-replicate spread is comparable — the effect may be unresolvable

The governing rule for this phase: every one of A1–A3 must be measured and
reported, and panel B must state plainly when the evidence does not support
the caption. A run that cannot separate the arms is a valid result, not a
failure to be tuned away.

1. Corrections to the stub's premises

Two claims in the stub body were checked against source and one is wrong.
Both are recorded here because the epic's handoff phase (#297) was told to
surface the second as an erratum, and it must not — see §0.

1a. The "known defect in the reference implementation" is REFUTED

The stub states: "legacy notebook cell 103 calls mc.get_conditional_loss_df()
where mc is the sigmoid CV collection — linear_mc is built and annotated but
never read. So the published Figure S10's middle panel does not show
linear-model CV loss."

This is not what the notebook does. The legacy notebook is recoverable at
fc89753:notebooks/spike-analysis.ipynb (the stub's 6c98b7b does not resolve
in this repo; it was added by fc89753 "latest before moving spike analysis"
and deleted by 9198572 "formatting"). By 0-based cell index:

idx exec source
67 37 cross_validation_df = mc.get_conditional_loss_df() — sigmoid
103 127 fit_params_linear["dataset"] = train; ... fit_models(...)the fit call, not a loss call
104 128 linear_mc = ModelCollection(linear_models_cv); linear_mc.add_validation_loss(test, overwrite=True)
105 132 cross_validation_df = linear_mc.get_conditional_loss_df()linear, and it IS read
106 133 saveas="shrinkage_analysis_linear_models" — the S10 figure

The stored execution_counts are monotone across 128 → 132 → 133, so the last
write to cross_validation_df before S10 was rendered was the linear one.
Cells 101/102 (exec 125/126) likewise rebind sparsity_df and corr_df from
the linear collection, so panels A and C are linear too.

Decision: do not file an erratum, and do not carry the bug claim into
the manuscript handoff (#297) as established fact.
The execution record is consistent with the
published figure being linear. Note the evidential limit honestly: stored
execution_counts record an execution order, not proof that the saved figure
file was rendered from it. The reproduction in this phase is the decisive
check
— if the reproduced panel B disagrees with the published figure, reopen
this premise rather than attributing the discrepancy elsewhere. The stub's separate warning that cells 98–100 rebind
module-level frames is still a real fragility — the two arms share variable
names, so a re-run in a different order would silently mix arms — and this spec
keeps the arms in separate namespaces for exactly that reason. But the
committed execution record shows the bug did not fire, and the published figure
is sound.

1b. The α claim is CONFIRMED, and is the central design constraint

Verified in multidms/jaxmodels.py:

  • Identity.__call__ (line 209) is a pure pass-through with no parameters.
  • Model.predict_score (line 307) computes
    α * (GE(φ(X)) − GE(φ(x_wt))), and φ(X) = β0 + X @ β (line 189).

Under Identity the GE is the identity, so this collapses exactly:

α · ((β0 + X@β) − (β0 + x_wt@β))  =  α · (X − x_wt) @ β

β0 cancels identically from predict_score and only the product α·β is
determined. Under Sigmoid the expit nonlinearity blocks the cancellation, so α
is identified there — and empirically it is: the cached sigmoid fit reports
α ≈ 17.8 (rep_1) to 19.3 (rep_2), far from alpha_init: 6.0
(results-prod-292-spike-fit-tuning/ge_params.csv).

Note β0 is not thereby unconstrained: _beta_ridge_penalty
(jaxmodels.py:532) penalizes (β0_d − β0_ref)² at beta0_ridge: 0.01, so
under Identity β0 receives no gradient from the likelihood but is still pulled
toward a common value by the ridge. Expect β0 in the output to be ridge-
determined rather than arbitrary.

α is not frozen under Identity — filter_spec_calibration sets α=α_true
(line 611–616) regardless of ge_type — so the optimizer simply faces a flat
direction and α drifts little from alpha_init.

The consequence for regularization is the load-bearing part. Both penalties are
in β units, with no α anywhere:

# jaxmodels.py:573-577
fusion_penalty += fusion_weights[d] * jnp.abs(model.φ[d].β - model.φ[ref].β).sum()
l2_penalty     += (β**2).sum()

So if the data pin α·β, then raising α shrinks β proportionally, and a fixed
fusionreg bites harder on the smaller β. Worse, the two penalties do not
rescale together: fusion is linear in β (a 6× β change needs a 6× λ change)
while L2 is quadratic (needs 36×). No single scalar rescaling of Phase 2's
ladder can keep both comparable. Phase 2's ladder is therefore not
transferable, and "divide λ by 6" is not a fix.


2. Design decisions

D1. α and the regularizers are OPEN QUESTIONS the run measures

Do not pin alpha_init to a value chosen to make the comparison come out.
An earlier draft of this spec recommended alpha_init: 1.0. That
recommendation was wrong, and the way it was wrong is the central lesson of
this phase, so it is recorded rather than deleted.

Why α is not a free gauge

Under Identity the likelihood depends only on the product α·β (§1b). It is
tempting to conclude α is an arbitrary gauge. That is true of the
likelihood and false of the objective, because the penalties depend on
β alone, with no α (jaxmodels.py:560, 573-577):

objective = loss(α·β)  +  l2reg·(β²).sum()  +  fusionreg·|β_d − β_ref|

Reparameterizing by the identified quantity c = α·β, the penalties become

effective l2reg = l2reg / α²        effective fusionreg = fusionreg / α

so α sets the effective regularization strength. l2reg is nonzero
(config.yaml:65, 1.0e-6) and is a single inherited scalar that is not
swept
, so a fusion-ladder calibration cannot absorb an α-induced change in
it. The objective is strictly decreasing in α, so α is weakly identified
through the penalties, with a gradient pushing it upward.

The three artifact mechanisms, quantified

These are why α cannot simply be picked. Measured from the cached sigmoid fit
(results-prod-292-spike-fit-tuning/ge_params.csv: α ≈ 17.79 rep_1 / 19.27
rep_2; max |β| ≈ 1.967 at lasso_choice), the identified product is
α·β ≈ 34.99:

alpha_init required max |β| effective l2reg beta_clip_range [-10,10]
17.79 (sigmoid's own) ≈ 1.97 3.2e-09 inactive
6.0 (fit-tier default) ≈ 5.83 2.8e-08 inactive
1.0 (earlier draft) 34.99 1.0e-06 BINDS HARD
  • A1 — clipping. beta_clip_range is applied in β units
    (jaxmodels.py:601-604). At α = 1 the required β far exceeds the bound, so
    the clip truncates precisely the large-effect mutations. That inflates the
    linear arm's loss and reproduces the caption's claim as a clipping
    artifact
    .
  • A2 — effective L2. Between α = 6 and α = 1 the effective L2 changes by
    36× and effective fusion by . Since fusion is linear in β and L2 is
    quadratic, no single scalar rescaling makes both comparable — which is also
    why Phase 2's ladder is not transferable and "divide λ by 6" is not a fix.
  • A3 — noise floor. The sigmoid's own validation curve
    (cross_validation_loss.csv, replicate-averaged) has a minimum only ≈ 0.7 %
    deep across the ladder, while the between-replicate spread at fixed λ is
    comparable. An unstable rung can inflate the objective by ~10 % — over an
    order of magnitude larger than the effect being measured.

What the phase does instead

The arm is fit at more than one α setting and the outcome is reported as a
sensitivity, not collapsed to a single number. Concretely:

  1. Primary arm — α at the sigmoid's own scale. Set alpha_init to the
    sigmoid's fitted α so the two arms sit on the same identified scale and the
    clip is demonstrably inactive. This is the setting in which a
    linear-vs-sigmoid loss comparison is least confounded.
  2. Sensitivity arm — α at the fit-tier default (6.0). Fit the same ladder
    again. If the linear-vs-sigmoid verdict flips between arms 1 and 2, the
    verdict is an artifact of the α/regularization choice and must be reported
    as such
    , not resolved by preferring one.
  3. l2reg is reported alongside every result on the identified scale
    (l2reg/α²), so a reader can see what shrinkage each arm actually received.

Whether to additionally fit at l2reg: 0.0 — which removes the quadratic term
so the fusion ladder is the only shrinkage and A2 disappears — is left
open for the implementer to decide from arm 1/2 evidence, and the choice
must be recorded in the PR's ## Deviations from spec.

Mandatory instrument checks

These are not optional validations; without them the arm's output is not
interpretable:

  • C1 — the clip must be proven inactive. Report max |β| per fit against
    beta_clip_range. If the bound binds anywhere on the ladder, the loss
    comparison at that rung is void and must be reported as void, not
    plotted. Widening the clip is permitted only as an explicit, recorded
    deviation, and requires re-reporting C1.
  • C2 — α drift is measured, not asserted. Report fitted α against its init
    per fit. Because the objective is not flat in α, a small drift is evidence
    about the calibration block's step budget (cal_kwargs: maxiter: 10), not
    proof of exact non-identifiability. Do not fail the run on drift; report it.
  • C3 — same-scale check. Fit one rung at two alpha_init values with the
    regularizers rescaled onto the identified scale and confirm the predictions
    agree. This is the direct test of the degeneracy claim; C2 is not.

The λ ladder (D1a)

The ladder is chosen empirically, per α setting, against a dimensionless
criterion — stop-codon shift sparsity — because sparsity is a fraction of
zeroed shifts and is therefore immune to the β-scale problem that contaminates
every other cross-arm quantity.

  1. Probe a coarse ladder on one replicate at the inherited (tol, maxiter).
    Anchor it on the α-scaling prediction λ_linear ≈ λ_sigmoid · (α_sig/α_lin)
    rather than sweeping blind; for arm 1 this predicts a ladder near Phase 2's.
  2. Record stop-codon sparsity per rung.
  3. Choose an 8–10 rung geometric ladder bracketing the region where sparsity
    climbs from ≈ 0 to ≈ 1. If the probe span does not bracket that range,
    extend it by decades until it does
    — do not silently accept a truncated
    window.
  4. Write the ladder into the config as a literal list with the probe's sparsity
    table quoted in a comment, following the evidence-in-config convention
    config_downstream.yaml already uses for lasso_choice.

Record the observed sparsity-vs-λ curve against the α-scaling prediction, so
the ladder rationale is confirmed by the probe rather than assumed from
§1b.

Fairness is necessary but not sufficient. Comparing each arm at its own
minimum-validation rung is required, and still does not make the comparison
safe on its own: per A3 the minima are shallow relative to replicate spread.
Panel B's obligations are specified in §D2.

D2. Reproduce S10 faithfully as a three-panel figure

Panels mirror S9 (shrinkage_analysis_trace_plots_beta), refit with Identity,
all sharing one categorical λ x-axis:

  • A — reproducibility: replicate-vs-replicate R² of shift params
    (Delta, BA.2). Producer: the linear arm must export its own
    linear_replicate_correlation.csv; the sigmoid panel reads
    library_replicate_correlation.csv, an evaluate output computed over the
    sigmoid collection, which has no linear equivalent until this arm makes
    one. Mirror that file's schema.

  • B — held-out loss: training vs validation loss, selected on
    dataset ∈ {training, validation}. Do not filter condition == "total"
    see the schema note in §3.2; the exported loss is already the total and has no
    condition column. This is the panel carrying the caption's scientific claim,
    and it therefore carries reporting obligations that the other panels do not:

    • B1 — show the noise floor. Plot/report per-replicate losses, not the
      replicate mean. The between-replicate spread at fixed λ is comparable to the
      entire λ effect (A3), and a mean hides exactly that.
    • B2 — show how well-resolved each minimum is. Report each arm's loss at
      its selected rung and at both neighbouring rungs. A minimum that is not
      resolved by the ladder cannot support a comparison.
    • B3 — state the verdict honestly, including the null. The notebook must
      compute the linear-vs-sigmoid validation gap and compare it against the
      within-arm replicate spread. If the gap does not exceed that spread, the
      notebook must say the figure does not support the caption.
      This sentence
      is a required output, not a fallback.
    • B4 — report the α sensitivity. State the verdict from both α arms
      (§D1). If they disagree, the panel reports the disagreement rather than
      choosing a winner.
    • B5 — report convergence at the compared rungs. Give n_outer_sweeps,
      final_objective_error, and whether maxiter was hit, for both arms. A
      loss difference accompanied by a convergence difference is not evidence
      about global epistasis; say so where it occurs.

    Both arms must be scored on identical variant sets: reuse
    cross_validation.ipynb's split logic verbatim — same seed, same
    train_frac, same aggregation, same all_muts_known test filter — and assert
    equal scored-variant counts per (replicate, condition). A mean-Huber
    comparison across different denominators is not a comparison.

    Note "total" is a sum over conditions (model.py:616,
    result["total"] = sum(result.values())), not a mean. Label the axis
    accordingly or divide by len(conditions) — do not inherit S9's "loss per
    variant" label, which is wrong by a factor of 3.

  • C — sparsity: percent of shift params equal to zero, split by mutation
    class. fit_sparsity.csv's mut_type has exactly two values in this
    pipeline — nonsynonymous and stop. There is no in-frame-deletion class:
    prepare_data drops every deletion-bearing variant, which is why the legacy
    Figure 3 deletion row rendered empty and was removed. Do not plot a third
    category.

Figure name: shrinkage_analysis_linear_models — the legacy saveas value
(cell 106), kept so a number-diff can match it against the manuscript. Because
S10 is descoped from EPIC #290's assembly phase (§0), this issue owns that
diff
: report the reproduced panel values against the published S10 caption
and si.tex as part of its own deliverable.

D3. Scope boundary: the linear arm needs its own cross-validation

Panel B is a held-out loss trace, so the arm must fit a CV split, not just a
point fit. This is the one place this arm is materially heavier than the
naive baseline arm landed in #294 (whose single-condition fits needed no
ladder and no CV).

Consequence: like manuscript_figures, the S10 figure requires CV. When
SKIP_CV is set the linear arm still fits (it is a fit in its own right) but
S10 is not built — mirroring the existing BUILD_FIGURES = not SKIP_CV
precedent, which drops a figure rather than emitting one with a missing panel.


3. Deliverables

3.1 experiments/scv2-spike/notebooks/_downstream.py

Add, as siblings of fit_naive_arm:

  • fit_linear_arm(func_score_df, conditions, replicates, fit_config, linear_config, cv_split=None, verbose=False)
    (models, convergence_df, ge_params_df). Multi-condition (unlike
    fit_single_condition), one fit per (replicate, fusionreg), calling
    multidms.Model(..., ge_type="Identity", ...) directly — bypassing
    _common.build_fit_params, exactly as fit_single_condition does.

    ⚠ The alpha_init override must be plumbed explicitly. fit_config is
    bound from spike["fitting"] (the fit-tier block, where alpha_init: 6.0
    lives). Simply declaring an alpha_init under spike.linear would be
    silently ignored, and the arm would inherit the fit-tier 6.0 — which
    would quietly decide Q1 and Q2 by accident rather than by measurement.
    fit_linear_arm therefore takes linear_config as its own parameter and
    overlays it: effective = {**fit_config, **linear_config}, with
    ge_type forced to "Identity". The overlay must be asserted in the
    notebook (print the effective alpha_init and ge_type) so the override is
    visible in the executed notebook rather than assumed.

  • derive_linear_shifts(models, reference, times_seen_threshold=1) → long-form
    mutation frame.

  • report_alpha_drift(ge_params_df, alpha_init) implementing C2 — a
    measurement and report, not an assertion (see §D1).

  • assert_clip_inactive(models, beta_clip_range) implementing C1 — reports
    max |β| per fit and marks any rung where the bound binds as void.

How the arm reaches ModelCollection without touching _common.py.
Both cross-validation (§D3) and stop-codon sparsity (§D1a) are
ModelCollection capabilities — ModelCollection.shift_sparsity()
(model_collection.py:1326) and the CV loss export — and are unreachable
from a bare dict of models. The route is nonetheless open, because
fit_models(params, ...) (model_collection.py:289) takes a plain dict:
fit_linear_arm builds its own params dict locally, with
"ge_type": ["Identity"] and the chosen alpha_init, and passes it to
fit_modelsModelCollection. The issue's prohibition is on editing
_common.build_fit_params (which is input: on all four fit-tier rules and
would invalidate the cached fit); it is not a prohibition on using
fit_models. Sparsity then comes from shift_sparsity(return_data=True)
exactly as evaluate.ipynb does, and CV from the split logic reused verbatim
from cross_validation.ipynb (§D2 panel B).

_downstream.py is the correct home: its docstring states it is a deliberate
sibling of _common.py precisely so downstream helpers cannot invalidate the
fit. _common.py must not be modified.

3.2 experiments/scv2-spike/notebooks/linear_baseline.ipynb

Papermill notebook mirroring naive_baseline.ipynb's shape. Parameters cell,
verbatim shape:

config_path = "config/config.yaml"
downstream_config_path = "config/config_downstream.yaml"
output_dir = None

Reads training_functional_scores.csv only. It must never load
fit_collection.pkl. Keeps all linear frames in their own names — never
rebinding anything a sigmoid cell owns (the fragility of §1a).

Outputs:

file schema
linear_muts.csv mutation, wts, sites, muts, replicate, fusionreg, condition, beta, shift, times_seen
linear_convergence.csv replicate, fusionreg, converged, n_outer_sweeps, final_objective_error, tol
linear_ge_params.csv replicate, fusionreg, condition, alpha, beta0, n_variants
linear_cv_loss.csv fusionreg, dataset_name, dataset, loss, mean_loss, fusionreg_cat
linear_sparsity.csv dataset_name, fusionreg, mut_type, mut_param, sparsity
linear_replicate_correlation.csv mirrors library_replicate_correlation.csv (panel A source)

Schemas deliberately match the sigmoid equivalents (fit_sparsity.csv,
cross_validation_loss.csv) column-for-column where the quantity is the same,
so panel code can be shared rather than forked.

⚠ Two schema facts, verified against the landed CSVs — do not "improve" them.

  1. linear_cv_loss.csv has no condition column. The real
    cross_validation_loss.csv header is
    fusionreg,dataset_name,dataset,loss,mean_loss,fusionreg_cat. It is built
    by melting total_loss_{training,validation}, which is already the
    total aggregate
    , so there is no per-condition axis to filter. Panel B
    must therefore select on dataset ∈ {training, validation} and must
    not filter condition == "total" — that filter is inherited from the
    legacy get_conditional_loss_df(), a different producer, and would
    raise KeyError here. §D2 is corrected accordingly.
  2. linear_ge_params.csv is hand-assembled, not a dump of
    get_ge_params_df().
    That method returns
    condition, alpha, beta0, bundle_sum, wildtype_latent, n_bundle_mutations
    — no n_variants. Phase 4's naive_ge_params.csv gets its shape because
    fit_single_condition builds the diagnostics dict itself. fit_linear_arm
    does the same, adding replicate, fusionreg, and n_variants. The V1
    portability note refers to the method being ge_type-agnostic, not to
    the CSV schema.

3.3 experiments/scv2-spike/config/*_downstream.yaml — ALL SIX

Add a spike.linear: block declaring zero new fit-tier keys. It goes in
all six downstream configs, not just the default one — verified: every one
of them already carries the sibling naive: block, and V6 requires a
test-profile run, which reads config_test_downstream.yaml:

config_downstream.yaml                          config_recompute_false_downstream.yaml
config_test_downstream.yaml                     config_recompute_false_test_downstream.yaml
config_experimental_downstream.yaml             config_recompute_false_maxiter200_downstream.yaml

A missing sibling fails evaluate. The ladder may be shortened in the test and
experimental variants.

  linear:
    # alpha_init is a REAL MODELING CHOICE here, not cosmetics. Under
    # ge_type Identity only the product alpha*beta is identified, so alpha
    # sets the effective regularization: l2reg/alpha^2 and fusionreg/alpha on
    # the identified scale. It also decides whether beta_clip_range binds --
    # at alpha=1 the required |beta| is ~35 against a [-10,10] clip.
    # Two arms are fit and compared as a sensitivity; neither is "the" answer.
    # See jaxmodels.py:307,601-604 and issue #293 section D1.
    alpha_init_primary: null      # set to the sigmoid's fitted alpha
    alpha_init_sensitivity: 6.0   # the fit-tier default, for the A2 check
    times_seen_threshold: 1
    conditions: [Delta, Omicron_BA1, Omicron_BA2]
    # Chosen by the sparsity-matching calibration of D1a; probe table quoted
    # here at implementation time.
    fusionreg_values: [...]

config/config.yaml must not change — that is a fit-tier edit and refits
everything.

3.4 experiments/scv2-spike/Snakefile

  • New rule linear_baseline, copying rule naive_baseline's input/output/
    params/shell block and its docstring reasoning. Declaring config=CONFIG_PATH
    and common=COMMON as input: is permitted and precedented (rule
    naive_baseline and rule evaluate both do it) because invalidation flows
    into a rule's outputs, and this rule produces no input of any fit-tier rule.
    The invariant to preserve: nothing here may become an input of prepare_data,
    fit_models, cross_validation, or evaluate, and the contents of
    config.yaml / _common.py must not change.

    The stub says the new rule must not declare config/common as input:.
    That instruction predates Phase 4, which established the opposite precedent
    with an explicit correctness argument. This spec follows the landed
    precedent; the deviation is recorded here and belongs in the PR's
    ## Deviations from spec.

  • Register "S10": "shrinkage_analysis_linear_models" in FIGURE_NAMES.

  • Add linear_baseline.ipynb + linear_muts.csv to _all_targets in the
    unconditional block (the arm is a fit in its own right), following the
    naive precedent's comment.

  • Add the linear CSVs to rule manuscript_figures's input:.

3.5 experiments/scv2-spike/notebooks/manuscript_figures.ipynb

Add a markdown + code cell pair following the Figure 3 template (cells 25/26):
read("linear_*.csv"), pivot to wide (quantity, replicate, condition), take an
explicit shared mutation index against mutations_df with a ValueError guard
when the intersection is < 2, render the three panels, and
savefig(fig, "shrinkage_analysis_linear_models", FIGURES_DIR, FORMATS, DPI).

Reads cache-boundary CSVs only; never fit_collection.pkl.

3.6 tests/test_linear_baseline.py

Phase 4 shipped tests/test_naive_baseline.py (8 tests) and its review still
found a vacuous test — one that passed because the regularizers were never
forwarded at all. Mirror the file and make V3 genuinely falsifiable: a test that
fails if fusionreg/l2reg/ge_type are dropped on the way into
multidms.Model. Add a test that ge_type is "Identity" in the constructed
params dict, since that is the entire point of the arm.

3.7 Docs

  • docs/spike_linear_baseline.nblink:
    {
        "path": "../experiments/scv2-spike/results/linear_baseline.ipynb"
    }
  • Add spike_linear_baseline to the Spike Analysis toctree in
    docs/index.rst, between spike_naive_baseline and
    spike_manuscript_figures.

The .nblink resolves through experiments/scv2-spike/results, an untracked
hand-made symlink that currently points at the most recent landed spike
results dir. pixi run docs will fail until this arm's run lands and the
symlink is repointed — expected, and part of V6 rather than a defect.


4. Validation

  • V1 — α drift under Identity is measured and reported (not asserted).
    Record every fitted α against the arm's own alpha_init and report the
    spread, for both α arms (§D1). Do
    not fail the run on drift: per the §D1 correction, the penalties break the
    degeneracy and α is expected to move somewhat. What the run must surface is
    the magnitude, because a large drift means the reported β scale — and hence
    the meaning of the arm's λ ladder — moved with it.

    Readback is safe: Model.get_ge_params_df() (model.py:865) derives
    alpha, beta0, bundle_sum, wildtype_latent from model structure only
    — none of it depends on ge_type, so the method is portable to the
    Identity arm unchanged. The CSV schema is not: n_variants is
    hand-assembled by the fitter, not returned by the method (see §3.2).

  • V2 — the arm did not disturb the sigmoid cache. After a full run,
    md5 of fit_collection.pkl and mutations_df.csv is unchanged, and
    snakemake -n reports nothing to be done for the fit-tier rules.

  • V3 — regularizers are genuinely forwarded. A test that would fail if
    fusionreg/l2reg were dropped on the way into multidms.Model — the
    Phase 4 review found exactly this class of vacuous test, so it is called out.

  • V4 — convergence is recorded, not asserted. Report converged/total per
    rung. A rung that fails to converge is reported and excluded from S10 with a
    visible marker, following the excluded_fusionreg precedent — never silently
    plotted.

  • V5 — shared mutation index. Panels comparing linear against sigmoid use
    one explicit intersected index, with the count printed. The Phase 4 review
    found panels silently using two different indices; this makes it impossible.

  • V7 — no result is reported from a void rung. Any rung where the β clip
    binds (C1) is excluded from every cross-arm comparison and labelled void in
    the figure, following the excluded_fusionreg marking precedent. Silent
    inclusion is the failure mode this guards.

  • V8 — the null outcome is a first-class result. The notebook must be able
    to conclude "the data do not separate the arms" and say so in-figure (B3).
    A run producing that conclusion is complete and correct, and must not be
    re-tuned to produce a difference.

  • V9 — pre-flight cache guard. Run snakemake -n and confirm no fit-tier
    rule is scheduled before any real run. Phase 4 destroyed the 1.76 GB
    cache with --forcerun and no dry-run; V2's md5 check is after-the-fact and
    would not have prevented it.

  • V6 — test profile runs. The whole rule completes under the test profile
    (subsampled), with thin-coverage guards so a correlation over n < 2 cannot
    crash the notebook (the Phase 4 run hit exactly this).

4b. Scope: this may want to be two PRs (open)

A fresh completeness review rated this unit oversized for one PR. The work is:
a ladder-calibration probe requiring its own fit; a multi-condition ladder fit
with cross-validation (structurally comparable to cross_validation.ipynb,
the pipeline's second-most-expensive rule); two α arms for the sensitivity;
six CSV exports; a Snakemake rule; a three-panel figure; tests; docs. For
calibration, Phase 4 — explicitly lighter, with no ladder and no CV — still
landed with seven numbered deviations and a spun-out follow-up issue.

Recommended split, left open for the implementer to confirm or reject:

  • 3a — the arm. _downstream.py helpers, the ladder probe, the config
    blocks, the Snakemake rule, the CSVs, tests, docs. Deliverable: a committed
    ladder with its probe table, plus linear_*.csv on disk and the C1/C2/C3
    instrument checks reported.
  • 3b — the figure. S10's three panels, the ladder-parameterized axis
    helpers, FIGURE_NAMES registration, and panel B's B1–B5 obligations.

The split also de-risks the serial dependency: 3b cannot be written until the
probe has run, so bundling them parks the figure work behind a fit.

Panel code is not shareable as-is. S9 hard-codes LADDER_SORTED from the
fit-tier fusionreg_values, and rung_x()/RUNG_POS are module-level
globals built from it; drop_excluded() is driven by the sigmoid-specific
excluded_fusionreg. The linear arm has a different ladder by design, so
these must be parameterized by ladder before reuse — calling rung_x() on a
linear λ raises KeyError.

4c. Open questions — deliberately unresolved

These are recorded as open because resolving them by fiat is how a baseline arm
turns into a rigged comparison. Each must be answered from the run's own
evidence
and the answer recorded in the PR's ## Deviations from spec.

# Question How it gets answered
Q1 What alpha_init does the primary arm use? Set to the sigmoid's fitted α so both arms share an identified scale and C1 shows the clip inactive. Confirm from ge_params.csv, don't hard-code from this spec.
Q2 Does the linear-vs-sigmoid verdict survive the α sensitivity? Compare arm 1 vs arm 2 (§D1). Disagreement is itself the reported result.
Q3 Should the linear arm fit at l2reg: 0.0? Decide from arm 1/2 evidence. Zeroing removes A2 entirely but changes the model being compared — a real trade, not a formality.
Q4 Is beta_clip_range widened for this arm? Only if C1 shows binding, only as a recorded deviation, and only with C1 re-reported afterward.
Q5 Does the caption's claim hold at all? Genuinely open. B3 requires the notebook to report "not supported" when the gap is within replicate spread.
Q6 One PR or two? §4b. Implementer's call on evidence from the probe's actual runtime.

None of Q1–Q6 may be resolved in the direction that makes the published
caption more likely to be confirmed.
If a choice is genuinely ambiguous,
report both outcomes.

5. Out of scope

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions