Naive (independent per-condition) baseline¶
The joint model estimates a shared reference effect \(\beta_m\) per mutation plus a condition-specific shift \(\Delta_{d,m}\), fitting every condition together under a lasso that pulls shifts toward zero. The naive approach is the obvious alternative: fit each condition on its own, then call the difference of the two independently inferred effects a “shift”.
This notebook builds that baseline so the two can be compared on equal footing. Its output feeds manuscript Figure 3, whose panels ask a single question – do the shifts replicate across independent libraries? – of each approach in turn.
Two things make the comparison fair, and both are deliberate:
Equal convergence. The naive arm inherits the joint arm’s
tolandmaxiter. The original analysis gave the naive fits a tenth of the iteration budget, so part of its poor showing was under-convergence rather than method.One shared mutation index. Both arms are filtered at
times_seen_threshold=1and compared on the same mutations. This knob moves the headline number more than anything else in the analysis.
Outline
Load the training functional scores
Fit one model per (replicate, condition)
Derive naive shifts by subtracting the reference condition’s betas
Report the per-condition scale parameters
Export
[1]:
import warnings
warnings.filterwarnings("ignore")
import os
import sys
sys.path.insert(0, "notebooks")
import pandas as pd
from _common import load_config
from _downstream import derive_naive_shifts, fit_naive_arm
[2]:
config_path = "config/config.yaml"
downstream_config_path = "config/config_downstream.yaml"
output_dir = None
[3]:
# Parameters
config_path = "config/config.yaml"
downstream_config_path = "config/config_downstream.yaml"
output_dir = "results-prod-294-naive-baseline-arm"
[4]:
config = load_config(config_path, downstream_config_path)
spike = config["spike"]
fit_config = spike["fitting"]
naive_config = spike["naive"]
reference = spike["reference"]
conditions = naive_config["conditions"]
times_seen_threshold = naive_config["times_seen_threshold"]
if output_dir is None:
output_dir = spike.get("output_dir", "results")
print(f"reference: {reference}")
print(f"conditions: {conditions}")
print(f"times_seen_threshold: {times_seen_threshold}")
print(f"convergence: tol={fit_config['tol']:g} maxiter={fit_config['maxiter']}")
reference: Omicron_BA1
conditions: ['Delta', 'Omicron_BA1', 'Omicron_BA2']
times_seen_threshold: 1
convergence: tol=1e-06 maxiter=500
Load the training data¶
Only the training CSV is read. fit_collection.pkl is 1.76 GB and needs roughly 7 GB of resident memory to unpickle; nothing here requires it, and loading it would make this rule far more expensive than the fits it performs.
[5]:
func_score_df = pd.read_csv(
os.path.join(output_dir, "training_functional_scores.csv")
).fillna({"aa_substitutions": ""})
replicates = sorted(func_score_df["replicate"].unique())
print(f"{len(func_score_df):,} variants across replicates {replicates}")
func_score_df.groupby(["replicate", "condition"]).size().rename("variants").to_frame()
303,007 variants across replicates [np.int64(1), np.int64(2)]
[5]:
| variants | ||
|---|---|---|
| replicate | condition | |
| 1 | Delta | 28046 |
| Omicron_BA1 | 68699 | |
| Omicron_BA2 | 60550 | |
| 2 | Delta | 27734 |
| Omicron_BA1 | 60538 | |
| Omicron_BA2 | 57440 |
Fit one model per (replicate, condition)¶
Each fit gets its own multidms.Data with reference set to that condition, so the model has a single condition and therefore no shift parameters at all. Two consequences follow:
There is no lasso ladder.
fusionregandbeta0_ridgeonly ever apply to non-reference conditions, so with one condition they are inert. The whole arm is six fits, not six times a ten-rung ladder, which is why it costs minutes rather than hours.The substitutions are already in BA.1 numbering, so each single-condition fit is expressed in the same mutation namespace as the others without any rewriting.
The convergence settings come from the joint arm’s own config. That is the fairness requirement: a baseline given a smaller budget than the method it is compared against tells you about the budget, not the method.
[6]:
models, convergence_df, ge_params_df = fit_naive_arm(
func_score_df, conditions, replicates, fit_config
)
n_converged = int(convergence_df["converged"].sum())
print(f"\n{n_converged}/{len(convergence_df)} fits converged")
convergence_df
rep 1 / Delta: converged=True sweeps=59 alpha=11.72 (64.7s)
rep 1 / Omicron_BA1: converged=True sweeps=63 alpha=13.06 (74.8s)
rep 1 / Omicron_BA2: converged=True sweeps=126 alpha=12.47 (134.5s)
rep 2 / Delta: converged=True sweeps=68 alpha=11.41 (78.0s)
rep 2 / Omicron_BA1: converged=True sweeps=73 alpha=13.14 (97.3s)
rep 2 / Omicron_BA2: converged=True sweeps=119 alpha=11.79 (155.0s)
6/6 fits converged
[6]:
| replicate | condition | converged | n_outer_sweeps | final_objective_error | tol | |
|---|---|---|---|---|---|---|
| 0 | 1 | Delta | True | 59 | 9.439099e-07 | 0.000001 |
| 1 | 1 | Omicron_BA1 | True | 63 | 8.843008e-07 | 0.000001 |
| 2 | 1 | Omicron_BA2 | True | 126 | 1.544366e-07 | 0.000001 |
| 3 | 2 | Delta | True | 68 | 7.893149e-07 | 0.000001 |
| 4 | 2 | Omicron_BA1 | True | 73 | 5.128082e-07 | 0.000001 |
| 5 | 2 | Omicron_BA2 | True | 119 | 1.766770e-07 | 0.000001 |
Derive the naive shifts¶
For each replicate and condition,
computed on the inner join of the per-condition mutation indices.
The inner join is not a detail. Thirty-two spike sites carry different wildtype residues across these backgrounds, so at those sites the same physical substitution is written differently in each condition – T19I in one and I19T in another. A label can therefore only appear in every condition’s index when the wildtype residue agrees, which is what makes a plain intersection safe. A union would quietly pair labels that do not denote the same substitution.
[7]:
naive_muts = derive_naive_shifts(
models, reference=reference, times_seen_threshold=times_seen_threshold
)
shared = naive_muts.groupby("replicate")["mutation"].nunique()
print("Shared mutation index per replicate:")
print(shared.to_string())
print("\nThe manuscript reports 5,934 mutations at times_seen_threshold=1.")
naive_muts.head()
Shared mutation index per replicate:
replicate
1 5926
2 5969
The manuscript reports 5,934 mutations at times_seen_threshold=1.
[7]:
| mutation | wts | sites | muts | replicate | condition | beta | naive_shift | times_seen | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | M1I | M | 1 | I | 1 | Delta | -0.978232 | 0.807336 | 3 |
| 1 | F2L | F | 2 | L | 1 | Delta | 0.077378 | -0.075700 | 8 |
| 2 | F2S | F | 2 | S | 1 | Delta | 0.125780 | 0.005258 | 5 |
| 3 | F2V | F | 2 | V | 1 | Delta | -0.156814 | -0.386161 | 1 |
| 4 | V3A | V | 3 | A | 1 | Delta | -0.081212 | -0.121458 | 11 |
The per-condition scale parameters¶
With one condition, share_alpha has nothing to share with, so each fit finds its own \(\alpha\) – the scale carrying latent phenotype to functional score. The naive shift \(\beta_d - \beta_{\text{ref}}\) therefore mixes a genuine difference in effect with a difference in scale.
This is not corrected here, and the direction of its effect is measured below rather than argued, because the plausible argument gets it backwards.
That argument runs: the mismatch is stable across replicates (the same condition draws a similar \(\alpha\) in library 1 and library 2), so it adds a correlated component to both replicates’ shifts, which can only push the naive replicate correlation up – making the joint-versus-naive gap reported downstream a lower bound on the true gap.
The premise holds: \(\alpha\) really is replicate-stable. The conclusion does not. Rescaling each condition’s betas by its fitted \(\alpha\) before subtracting raises the naive replicate \(R^2\) rather than lowering it, so the scale mismatch is suppressing the naive correlation, not inflating it, and the gap is not a lower bound. The check is run below so the claim travels with its evidence.
[8]:
print(ge_params_df.to_string(index=False))
alpha_spread = ge_params_df.groupby("condition")["alpha"].agg(["min", "max"])
print("\nAlpha by condition (replicate-stable if min and max are close):")
print(alpha_spread.to_string())
# Does the alpha mismatch inflate or suppress the naive replicate
# correlation? Rescale each condition's betas by its own fitted alpha before
# subtracting, and compare the resulting replicate R^2 against the
# uncorrected one. Rescaling removes the scale difference, so if the
# uncorrected number is the inflated one, this should come out LOWER.
from scipy.stats import pearsonr
alpha = ge_params_df.set_index(["replicate", "condition"])["alpha"]
wide = naive_muts.pivot_table(
index="mutation", columns=["replicate", "condition"], values="beta"
)
print("\nNaive shift replicate R^2, uncorrected vs alpha-rescaled:")
for cond in [c for c in conditions if c != reference]:
raw, scaled = {}, {}
for rep in replicates:
b_cond, b_ref = wide[(rep, cond)], wide[(rep, reference)]
raw[rep] = b_cond - b_ref
scaled[rep] = alpha[(rep, cond)] * b_cond - alpha[(rep, reference)] * b_ref
raw_df = pd.DataFrame(raw).dropna()
scaled_df = pd.DataFrame(scaled).dropna()
raw_r2 = pearsonr(raw_df.iloc[:, 0], raw_df.iloc[:, 1])[0] ** 2
scaled_r2 = pearsonr(scaled_df.iloc[:, 0], scaled_df.iloc[:, 1])[0] ** 2
verdict = "suppressing" if scaled_r2 > raw_r2 else "inflating"
print(
f" {cond:14s} uncorrected {raw_r2:.3f} rescaled {scaled_r2:.3f}"
f" -> alpha mismatch is {verdict}"
)
replicate condition alpha beta0 n_variants
1 Delta 11.717550 -0.515767 28046
1 Omicron_BA1 13.056016 -0.800739 68699
1 Omicron_BA2 12.470536 -0.825371 60550
2 Delta 11.405512 -0.653749 27734
2 Omicron_BA1 13.142683 -0.821267 60538
2 Omicron_BA2 11.792196 -0.700683 57440
Alpha by condition (replicate-stable if min and max are close):
min max
condition
Delta 11.405512 11.717550
Omicron_BA1 13.056016 13.142683
Omicron_BA2 11.792196 12.470536
Naive shift replicate R^2, uncorrected vs alpha-rescaled:
Delta uncorrected 0.340 rescaled 0.416 -> alpha mismatch is suppressing
Omicron_BA2 uncorrected 0.161 rescaled 0.179 -> alpha mismatch is suppressing
Export¶
Three tables, all long-form. The legacy analysis carried these as wide columns named {replicate}-{condition}_beta, which is easy to mis-parse; one row per (mutation, replicate, condition) says the same thing without encoding structure in column names.
[9]:
outputs = {
"naive_muts.csv": naive_muts,
"naive_convergence.csv": convergence_df,
"naive_ge_params.csv": ge_params_df,
}
for name, frame in outputs.items():
path = os.path.join(output_dir, name)
frame.to_csv(path, index=False)
print(f" wrote {path} ({len(frame):,} rows)")
wrote results-prod-294-naive-baseline-arm/naive_muts.csv (35,685 rows)
wrote results-prod-294-naive-baseline-arm/naive_convergence.csv (6 rows)
wrote results-prod-294-naive-baseline-arm/naive_ge_params.csv (6 rows)