Skip to content

Commit abd4971

Browse files
VladimirShitovVladimir Shitovclaude
authored
Restore the per-cell scale of senkin_tmp and ss_opm predictions (#80)
Both 2022 competition solutions predict a z-scored profile per cell (the competition scored per-cell Pearson only). Mapping that back with one global slope and intercept gave every predicted cell the same mean and standard deviation, so per-gene correlations, overall correlations and RMSE/MAE scored the missing per-cell level rather than the methods. src/utils/cell_scale.py predicts each cell's target mean and log standard deviation from its input (ridge regression on a truncated SVD of the normalized input plus two sequencing-depth features) and restores them, with the spread shrunk by one least-squares factor fitted on training-cell predictions (out-of-fold for senkin_tmp). Per-cell correlations are unchanged. ss_opm_predict keeps the old global map for model bundles trained before this change. Co-authored-by: Vladimir Shitov <vladimir.shitov@helmholtz-munich.de> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0bb3beb commit abd4971

11 files changed

Lines changed: 174 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126

127127
* `guanlab_dengkw_pm`: Solve the kernel ridge regression with `scipy.linalg.cho_factor()`/`cho_solve()` instead of `KernelRidge`, whose `scipy.linalg.solve()` raised a `MemoryError` or segfaulted beyond ~30k cells with the OpenBLAS in this image. Predictions are unchanged; the method failed on every dataset except `bmmc_multiome`.
128128

129+
* `senkin_tmp_train`, `ss_opm_train`, `ss_opm_predict`: Give every predicted cell its own level and spread instead of one global slope and intercept. Both 2022 competition solutions predict a z-scored profile per cell, so every cell came out with the same mean (0.5955 on `bmmc_cite`, where the true per-cell mean ranges from 0.16 to 2.83): per-cell correlations were unaffected, but per-gene correlations, overall correlations and RMSE/MAE scored that missing level, not the methods. The new `src/utils/cell_scale.py` predicts each cell's target mean and log standard deviation from its input (ridge regression on a truncated SVD plus two sequencing-depth features) and shrinks the restored spread by one factor fitted on training-cell predictions. `ss_opm_predict` still applies the old global map to model bundles trained before this change.
130+
129131
# task_predict_modality 0.1.1
130132

131133
## NEW FUNCTIONALITY

‎src/methods/senkin_tmp/senkin_tmp/config.vsh.yaml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ description: |
1111
the TSVD-reduced LightGBM predictions, together with the feature blocks (each z-scored per
1212
cell), with two neural networks: a bidirectional GRU with cosine-similarity loss and a dense
1313
bidirectional GRU with MSE loss. Final predictions are a weighted blend (55% cosine, 45% MSE)
14-
of per-fold averaged outputs, rescaled to the target scale with a global affine transform.
14+
of per-fold averaged outputs. These are z-scored per cell (the competition scored per-cell
15+
correlations only), so each test cell gets its own protein level and spread back, predicted
16+
from its RNA profile by a ridge regression fitted on the training cells.
1517
references:
1618
doi:
1719
- 10.64898/2026.02.24.707614

‎src/methods/senkin_tmp/senkin_tmp_train/config.vsh.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ resources:
1313
- path: script.py
1414
type: python_script
1515
- path: /src/utils/exit_codes.py
16+
- path: /src/utils/cell_scale.py
1617
arguments:
1718
- name: "--n_folds"
1819
type: integer

‎src/methods/senkin_tmp/senkin_tmp_train/script.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
## VIASH END
4444

4545
sys.path.append(meta["resources_dir"])
46+
from cell_scale import CellScale
4647
from exit_codes import exit_non_applicable
4748

4849
# The original solution corrected batch effects per day and computed gene-protein correlations per donor and day.
@@ -240,12 +241,16 @@ def _nn_inputs(idx):
240241
test_preds = zscore(test_preds_cos) * 0.55 + zscore(test_preds_mse) * 0.45
241242

242243
# The original solution was scored with a per-cell Pearson correlation only, so its predictions are z-scored per
243-
# cell. The benchmark also computes RMSE/MAE, so bring the predictions back to the scale of the normalized proteins
244-
# with a single global affine transform fitted on the out-of-fold training predictions. One (slope, intercept) pair
245-
# for all cells and proteins leaves every per-cell and per-protein correlation untouched.
246-
slope, intercept = np.polyfit(train_preds.ravel(), Y_prot_train.ravel(), deg=1)
247-
logger.info(f"Rescaling z-scored predictions to the target scale: slope {slope:.4f}, intercept {intercept:.4f}")
248-
test_preds = test_preds * slope + intercept
244+
# cell. Give every test cell its own protein level and spread back, predicted from its RNA profile (see
245+
# src/utils/cell_scale.py); the shrinkage of the spread is fitted on the out-of-fold training predictions. Per-cell
246+
# correlations are unchanged; per-protein correlations and RMSE/MAE now see each cell's level.
247+
def _counts(adata):
248+
return adata.layers["counts"] if "counts" in adata.layers else None
249+
250+
cell_scale = CellScale().fit(adata_rna_train.layers["normalized"], Y_prot_train, _counts(adata_rna_train))
251+
cell_scale.fit_spread(train_preds, adata_rna_train.layers["normalized"], Y_prot_train, _counts(adata_rna_train))
252+
logger.info(f"Restoring the per-cell protein scale (spread shrinkage {cell_scale.spread_:.3f})")
253+
test_preds = cell_scale.apply(test_preds, adata_rna_test.layers["normalized"], _counts(adata_rna_test))
249254

250255
# ---------------------------------------------------------------------------
251256
# Save the model: the solution is transductive, so the test predictions themselves are the model. They are stored as an

‎src/methods/ss_opm/ss_opm/config.vsh.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ description: |
1010
Open Problems Multimodal Single-Cell Integration Kaggle competition. The inputs the
1111
original derived from the competition tables (standardized per-cell and per-batch
1212
statistics, the CITE gene masks built from HGNC and Reactome) are rebuilt from the
13-
task's files, and the per-cell z-scored output is mapped to the target scale with a
14-
global affine transform.
13+
task's files. The output is z-scored per cell (the competition scored per-cell correlations
14+
only), so each test cell gets its own target level and spread back, predicted from its input
15+
profile by a ridge regression fitted on the training cells.
1516
references:
1617
doi:
1718
- 10.1101/2022.04.11.487796

‎src/methods/ss_opm/ss_opm_common.py‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -466,13 +466,6 @@ def informative_cells(targets):
466466
return variances > 1e-12
467467

468468

469-
def fit_prediction_rescaling(predictions, targets):
470-
"""The model predicts per-cell z-scores. One global affine map to the target scale keeps every per-cell and
471-
per-feature correlation unchanged and makes the RMSE/MAE metrics meaningful."""
472-
slope, intercept = np.polyfit(np.asarray(predictions, dtype=np.float64).ravel(), to_dense(targets, np.float64).ravel(), deg=1)
473-
return {"slope": float(slope), "intercept": float(intercept)}
474-
475-
476469
def save_json(path, payload):
477470
with open(path, "w") as handle:
478471
json.dump(payload, handle, indent=2)

‎src/methods/ss_opm/ss_opm_predict/config.vsh.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ resources:
99
- type: python_script
1010
path: script.py
1111
- path: ../ss_opm_common.py
12+
- path: /src/utils/cell_scale.py
1213
engines:
1314
- type: docker
1415
image: openproblems/base_pytorch_nvidia:1

‎src/methods/ss_opm/ss_opm_predict/script.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
## VIASH END
1919

2020
sys.path.append(meta["resources_dir"])
21+
from cell_scale import CellScale # noqa: E402
2122
from ss_opm_common import ( # noqa: E402
2223
apply_runtime_patches,
2324
apply_standardization,
@@ -77,9 +78,16 @@
7778

7879
print("Predicting...", flush=True)
7980
predictions = model.predict(x=test_inputs, preprocessed_x=preprocessed_test_inputs, metadata=test_metadata)
80-
rescaling = task_info["prediction_rescaling"]
81-
predictions = predictions * rescaling["slope"] + rescaling["intercept"]
82-
predictions = np.nan_to_num(predictions, nan=rescaling["intercept"], posinf=rescaling["intercept"], neginf=rescaling["intercept"])
81+
# The network outputs per-cell z-scores, so a non-finite value is set to 0, the cell's mean.
82+
predictions = np.nan_to_num(predictions, nan=0.0, posinf=0.0, neginf=0.0)
83+
cell_scale_path = os.path.join(par["input_model"], "cell_scale.npz")
84+
if os.path.exists(cell_scale_path):
85+
test_counts = to_sparse_csr(input_test_mod1.layers["counts"]) if "counts" in input_test_mod1.layers else None
86+
predictions = CellScale.load(cell_scale_path).apply(predictions, test_inputs, test_counts)
87+
else:
88+
# models trained before the per-cell scale was introduced store one global affine map
89+
rescaling = task_info["prediction_rescaling"]
90+
predictions = predictions * rescaling["slope"] + rescaling["intercept"]
8391
assert predictions.shape == (input_test_mod1.n_obs, mod2_var.shape[0])
8492

8593
# ---- Write ----

‎src/methods/ss_opm/ss_opm_train/config.vsh.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ arguments:
4646
type: integer
4747
default: 5000
4848
description: |
49-
Number of training cells used to fit the global affine map from the model's per-cell z-scored
50-
output to the target scale.
49+
Number of training cells whose predictions are used to fit the shrinkage of the restored
50+
per-cell spread (see src/utils/cell_scale.py).
5151
resources:
5252
- type: python_script
5353
path: script.py
5454
- path: ../ss_opm_common.py
55+
- path: /src/utils/cell_scale.py
5556
engines:
5657
- type: docker
5758
image: openproblems/base_pytorch_nvidia:1

‎src/methods/ss_opm/ss_opm_train/script.py‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
## VIASH END
2828

2929
sys.path.append(meta["resources_dir"])
30+
from cell_scale import CellScale # noqa: E402
3031
from ss_opm_common import ( # noqa: E402
3132
ROW_BLOCK,
3233
BatchSingularVectors,
@@ -36,7 +37,6 @@
3637
compute_batch_input_medians,
3738
compute_cell_statistics,
3839
download_reference_files,
39-
fit_prediction_rescaling,
4040
fit_standardization,
4141
gene_symbols_from_var_names,
4242
informative_cells,
@@ -98,6 +98,8 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
9898
print(f"Task type: {task_type}", flush=True)
9999

100100
train_inputs = to_sparse_csr(input_train_mod1.layers["normalized"]).astype(np.float32)
101+
# raw input counts, only for the sequencing-depth features of the per-cell scale model
102+
train_counts = to_sparse_csr(input_train_mod1.layers["counts"]) if "counts" in input_train_mod1.layers else None
101103
train_targets = to_sparse_csr(input_train_mod2.layers["normalized"]).astype(np.float32)
102104
train_batches = input_train_mod1.obs["batch"].astype(str).values
103105
test_inputs = to_sparse_csr(input_test_mod1.layers["normalized"]).astype(np.float32) if input_test_mod1 is not None else None
@@ -114,6 +116,7 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
114116
if not keep.all():
115117
print(f"Dropping {(~keep).sum()} training cells with a constant target vector", flush=True)
116118
train_inputs, train_targets, train_batches = train_inputs[keep], train_targets[keep], train_batches[keep]
119+
train_counts = train_counts[keep] if train_counts is not None else None
117120

118121
# ---- Metadata: the derived files of the original pipeline, rebuilt from the h5ad files ----
119122
print("Computing cell statistics...", flush=True)
@@ -233,8 +236,9 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
233236
gc.collect()
234237

235238
# ---- Prediction scale ----
236-
# The network outputs per-cell z-scores (the competition only scored per-cell correlations). Fit one global affine
237-
# map from those to the normalized targets on a subsample of training cells; it leaves every correlation unchanged.
239+
# The network outputs per-cell z-scores (the competition only scored per-cell correlations). Every cell gets its own
240+
# target level and spread back, predicted from its input profile (src/utils/cell_scale.py); the shrinkage of the spread
241+
# is fitted on predictions for a subsample of training cells. Per-cell correlations are unchanged.
238242
rng = np.random.default_rng(SEED)
239243
n_rescaling_cells = min(par["n_rescaling_cells"], train_inputs.shape[0])
240244
rescaling_cells = np.sort(rng.choice(train_inputs.shape[0], size=n_rescaling_cells, replace=False))
@@ -243,8 +247,14 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
243247
preprocessed_x=preprocessed_inputs[rescaling_cells],
244248
metadata=train_metadata.iloc[rescaling_cells].reset_index(drop=True),
245249
)
246-
rescaling = fit_prediction_rescaling(train_predictions, train_targets[rescaling_cells])
247-
print(f"Prediction rescaling: slope {rescaling['slope']:.4f}, intercept {rescaling['intercept']:.4f}", flush=True)
250+
cell_scale = CellScale().fit(train_inputs, train_targets, train_counts)
251+
cell_scale.fit_spread(
252+
train_predictions,
253+
train_inputs[rescaling_cells],
254+
train_targets[rescaling_cells],
255+
train_counts[rescaling_cells] if train_counts is not None else None,
256+
)
257+
print(f"Per-cell scale: spread shrinkage {cell_scale.spread_:.4f}", flush=True)
248258

249259
# ---- Save ----
250260
print("Saving model...", flush=True)
@@ -257,6 +267,7 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
257267
with open(os.path.join(par["output"], "batch_singular_vectors.pickle"), "wb") as handle:
258268
pickle.dump(batch_singular_vectors, handle)
259269
mod2_var.to_parquet(os.path.join(par["output"], "mod2_var.parquet"))
270+
cell_scale.save(os.path.join(par["output"], "cell_scale.npz"))
260271
save_json(
261272
os.path.join(par["output"], "task_info.json"),
262273
{
@@ -267,7 +278,6 @@ def _float32_train_step_forward(self, batch, training_length_ratio):
267278
"day_pattern": par["day_pattern"],
268279
"donor_pattern": par["donor_pattern"],
269280
"cell_statistics_standardization": cell_statistics_standardization,
270-
"prediction_rescaling": rescaling,
271281
},
272282
)
273283
print("Done!", flush=True)

0 commit comments

Comments
 (0)