F4RUKYLDRM commited on
Commit
62a8628
·
1 Parent(s): 9a10d98

Fix BRCA training collapse with lab-grid defaults

Browse files

Training on BRCA with the previous defaults (Adam, lr 1e-3, weight decay
0.01) collapsed: every sample got the same prediction (macro F1 0.133).

- Default to the configuration from the Bozdag Lab grid that was stable
across 8 seeds on BRCA: Adam, lr 0.01, L2 0.01, batch 32, projection
64, tanh/relu/tanh activations, 200 epochs (macro F1 0.749 +/- 0.018).
- Make the three activation functions and SGD momentum (default 0.03)
configurable in Hyperparameters. model.py and the artifact format are
unchanged; artifacts already store their activations.
- Make the automatic GenePT choice the lab-recommended single-cell file.
- Default to a 75/25 stratified train/validation split, with helper text
and a clear error when a class is too small to stratify.
- Fix the Results page training-history plot, which was never populated.
- Add stable element ids for e2e tests and a layout tweak for short
desktop screens.
- Tests: split, leakage, class weights, history plot, activations,
momentum and defaults (22 passing).

Files changed (4) hide show
  1. app.py +49 -25
  2. biolmnet/training.py +64 -19
  3. biolmnet/ui/styles.py +46 -0
  4. tests/test_core.py +223 -1
app.py CHANGED
@@ -34,6 +34,7 @@ from biolmnet.data import (
34
  validate_and_align_omics,
35
  )
36
  from biolmnet.training import (
 
37
  Hyperparameters,
38
  ModelBundle,
39
  pathway_importance,
@@ -45,7 +46,7 @@ from biolmnet.ui.styles import CSS
45
 
46
 
47
  GENEPT_OPTIONS = {
48
- "Auto — bulk or single-cell based on source": "auto",
49
  "Bulk · large-3 context": "embedding_original_large_3.parquet",
50
  "Bulk · original ada-text context": "embedding_original_ada_text.parquet",
51
  "Single-cell · cell type, tissue, drug & pathway": (
@@ -101,9 +102,8 @@ def _resolve_embedding_file(option: str, source_mode: str, example_dataset: str)
101
  selected = GENEPT_OPTIONS[option]
102
  if selected != "auto":
103
  return selected
104
- if source_mode == "BioLM-NET examples" and example_dataset == "scTrioseq2":
105
- return "embedding_associations_cell_type_tissue_drug_pathway_openai_large.parquet"
106
- return "embedding_original_large_3.parquet"
107
 
108
 
109
  def _graph_preview_html(mask_density: str, gene_nodes: str, pathway_units: str, genept_dim: str) -> str:
@@ -514,6 +514,14 @@ def _loss_plot(history: list[dict[str, float]]) -> go.Figure:
514
  return figure
515
 
516
 
 
 
 
 
 
 
 
 
517
  def estimate_training_duration(
518
  workspace: PreparedWorkspace | None,
519
  epochs: int,
@@ -949,6 +957,7 @@ def refresh_results(bundle: ModelBundle | None, run_meta: dict):
949
  "Build the biological architecture, train a model, or run prediction to populate this page."
950
  ),
951
  gr.update(visible=False),
 
952
  )
953
 
954
  macro_auc = run_meta.get("macro_auc")
@@ -965,7 +974,8 @@ def refresh_results(bundle: ModelBundle | None, run_meta: dict):
965
  )
966
  + "</div>"
967
  )
968
- return stats, gr.update(visible=True), "", gr.update(visible=True)
 
969
 
970
 
971
  THEME = gr.themes.Base(primary_hue="gray", neutral_hue="gray")
@@ -986,18 +996,19 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
986
  session_id_state = gr.State("")
987
  intro_step_state = gr.State(0)
988
  intro_dismissed_state = gr.BrowserState(False, storage_key="biolmnet_intro_dismissed")
 
989
 
990
  with gr.Row(elem_classes=["app-shell"]):
991
  with gr.Column(elem_classes=["rail-col"]):
992
  with gr.Column(elem_classes=["rail"]):
993
- gr.HTML(ui.brand_block_html())
994
  rail_row_htmls = []
995
  rail_row_buttons = []
996
  with gr.Column(elem_classes=["rail-nav"]):
997
  for index, label in enumerate(STAGE_LABELS):
998
  with gr.Column(elem_classes=["rail-row-wrap"]):
999
  row_html = gr.HTML(ui.rail_row_html(index + 1, label, "on" if index == 0 else "off", "Active" if index == 0 else "Locked"))
1000
- row_button = gr.Button("", elem_classes=["rail-row-click"])
1001
  rail_row_htmls.append(row_html)
1002
  rail_row_buttons.append(row_button)
1003
  with gr.Column(elem_classes=["rail-spacer", "rail-plate-wrap"]):
@@ -1011,7 +1022,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1011
  intro_open_button = gr.Button("Introduction", size="sm", variant="secondary", elem_classes=["btn-ghost", "intro-open-btn"])
1012
 
1013
  # ── Data & Priors ────────────────────────────────────────
1014
- with gr.Column(visible=True) as data_page:
1015
  with gr.Row(elem_classes=["pghd"]):
1016
  gr.HTML(
1017
  ui.title_block_html(
@@ -1028,7 +1039,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1028
  source_mode = gr.Radio(
1029
  ["BioLM-NET examples", "GitHub folder", "Upload files"],
1030
  value="BioLM-NET examples", show_label=False, container=False,
1031
- elem_classes=["seg-radio"],
1032
  )
1033
  with gr.Row(elem_classes=["srow-row", "first"]):
1034
  with gr.Column(scale=1, min_width=0):
@@ -1038,7 +1049,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1038
  gr.HTML('<div class="field-label">Repository dataset</div>')
1039
  example_dataset = gr.Dropdown(
1040
  ["BRCA", "COAD", "GBM", "scTrioseq2"], value="BRCA",
1041
- show_label=False, container=False,
1042
  )
1043
  with gr.Column(visible=False) as github_group:
1044
  github_folder = gr.Textbox(
@@ -1144,12 +1155,13 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1144
  "Re-validate inputs", size="sm", variant="secondary", elem_classes=["actbar-secondary-btn"]
1145
  )
1146
  prepare_button = gr.Button(
1147
- "Build biological architecture", size="sm", variant="primary", elem_classes=["actbar-primary-btn"]
 
1148
  )
1149
  preparation_status = gr.HTML(visible=False)
1150
 
1151
  # ── Train Model ──────────────────────────────────────────
1152
- with gr.Column(visible=False) as train_page:
1153
  with gr.Row(elem_classes=["pghd"]):
1154
  gr.HTML(
1155
  ui.title_block_html(
@@ -1165,22 +1177,22 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1165
  with gr.Column(min_width=140):
1166
  gr.HTML(ui.esc("Epochs"))
1167
  with gr.Column():
1168
- epochs = gr.Slider(5, 200, value=50, step=5, show_label=False, container=False)
1169
  with gr.Row(elem_classes=["srow-row"]):
1170
  with gr.Column(min_width=140):
1171
  gr.HTML(ui.esc("Batch size"))
1172
  with gr.Column():
1173
- batch_size = gr.Dropdown([8, 16, 32, 64, 128], value=16, show_label=False, container=False)
1174
  with gr.Row(elem_classes=["srow-row"]):
1175
  with gr.Column(min_width=140):
1176
  gr.HTML(ui.esc("Learning rate"))
1177
  with gr.Column():
1178
- learning_rate = gr.Number(value=0.001, minimum=0.000001, show_label=False, container=False)
1179
  with gr.Row(elem_classes=["srow-row"]):
1180
  with gr.Column(min_width=140):
1181
  gr.HTML(ui.esc("L2 weight decay"))
1182
  with gr.Column():
1183
- weight_decay = gr.Number(value=0.01, minimum=0, show_label=False, container=False)
1184
  with gr.Row(elem_classes=["srow-row", "last"]):
1185
  with gr.Column(min_width=140):
1186
  gr.HTML(ui.esc("Dropout"))
@@ -1202,7 +1214,13 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1202
  with gr.Column(min_width=140):
1203
  gr.HTML(ui.esc("Validation fraction"))
1204
  with gr.Column():
1205
- validation_fraction = gr.Slider(0.1, 0.4, value=0.2, step=0.05, show_label=False, container=False)
 
 
 
 
 
 
1206
  with gr.Row(elem_classes=["srow-row"]):
1207
  with gr.Column(min_width=140):
1208
  gr.HTML(ui.esc("Optimizer"))
@@ -1221,11 +1239,16 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1221
  gr.HTML('<div class="mono" style="margin-bottom:9px">Training result</div>')
1222
  training_status = gr.HTML(_status("Prepare the architecture before training."))
1223
  loss_plot = gr.Plot(label="Loss by epoch", show_label=False)
1224
- gr.HTML('<div class="mono" style="margin:20px 0 9px">Paper-faithful defaults</div>')
1225
  gr.HTML(
1226
  "".join(
1227
  [
1228
  ui.kv_plain_row("First layer", "trainable W ⊙ M"),
 
 
 
 
 
1229
  ui.kv_plain_row("PDI weights", "binary"),
1230
  ui.kv_plain_row("PPI weights", "STRING norm."),
1231
  ui.kv_plain_row("Attention", "GenePT query"),
@@ -1238,11 +1261,12 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1238
  train_actionbar_note = gr.HTML('<div class="actbar-note">Set hyperparameters, then train.</div>')
1239
  with gr.Row(elem_classes=["actbar-buttons"]):
1240
  train_button = gr.Button(
1241
- "Train BioLM-NET on ZeroGPU", size="sm", variant="primary", elem_classes=["actbar-primary-btn"]
 
1242
  )
1243
 
1244
  # ── Export Artifacts ─────────────────────────────────────
1245
- with gr.Column(visible=False) as export_page:
1246
  with gr.Row(elem_classes=["pghd"]):
1247
  gr.HTML(
1248
  ui.title_block_html(
@@ -1270,7 +1294,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1270
  gr.HTML('<div class="actbar-note">Bundle written to a temp file on export · not persisted between Space restarts</div>')
1271
 
1272
  # ── Predict ──────────────────────────────────────────────
1273
- with gr.Column(visible=False) as predict_page:
1274
  with gr.Row(elem_classes=["pghd"]):
1275
  gr.HTML(
1276
  ui.title_block_html(
@@ -1334,12 +1358,12 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1334
  with gr.Row(elem_classes=["actbar-buttons"]):
1335
  predict_button = gr.Button(
1336
  "Run inference", size="sm", variant="primary", interactive=False,
1337
- elem_classes=["actbar-primary-btn"],
1338
  )
1339
  prediction_status = gr.HTML(visible=False)
1340
 
1341
  # ── Results ──────────────────────────────────────────────
1342
- with gr.Column(visible=False) as results_page:
1343
  with gr.Row(elem_classes=["pghd"]):
1344
  gr.HTML(
1345
  ui.title_block_html(
@@ -1642,7 +1666,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1642
  ).then(
1643
  refresh_results,
1644
  inputs=[model_state, run_meta_state],
1645
- outputs=[results_stats, results_stats_row, results_empty_note, results_content],
1646
  show_progress="hidden",
1647
  ).then(
1648
  lambda df: gr.update(value=df), inputs=[validation_predictions_state], outputs=[results_validation_table],
@@ -1718,7 +1742,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1718
  show_progress="hidden",
1719
  ).then(
1720
  refresh_results, inputs=[model_state, run_meta_state],
1721
- outputs=[results_stats, results_stats_row, results_empty_note, results_content],
1722
  show_progress="hidden",
1723
  )
1724
 
 
34
  validate_and_align_omics,
35
  )
36
  from biolmnet.training import (
37
+ DEFAULT_VALIDATION_FRACTION,
38
  Hyperparameters,
39
  ModelBundle,
40
  pathway_importance,
 
46
 
47
 
48
  GENEPT_OPTIONS = {
49
+ "Auto — lab-recommended single-cell context": "auto",
50
  "Bulk · large-3 context": "embedding_original_large_3.parquet",
51
  "Bulk · original ada-text context": "embedding_original_ada_text.parquet",
52
  "Single-cell · cell type, tissue, drug & pathway": (
 
102
  selected = GENEPT_OPTIONS[option]
103
  if selected != "auto":
104
  return selected
105
+ # Bozdag Lab recommendation (J. Rifat, 2026-10-17) for every source.
106
+ return "embedding_associations_cell_type_tissue_drug_pathway_openai_large.parquet"
 
107
 
108
 
109
  def _graph_preview_html(mask_density: str, gene_nodes: str, pathway_units: str, genept_dim: str) -> str:
 
514
  return figure
515
 
516
 
517
+ def _validation_split_note(validation_fraction: float) -> str:
518
+ validation_percent = round(float(validation_fraction) * 100)
519
+ return (
520
+ f'<div class="field-hint">{validation_percent}% of samples are held out for validation '
521
+ f"(stratified by class). The remaining {100 - validation_percent}% are used for training.</div>"
522
+ )
523
+
524
+
525
  def estimate_training_duration(
526
  workspace: PreparedWorkspace | None,
527
  epochs: int,
 
957
  "Build the biological architecture, train a model, or run prediction to populate this page."
958
  ),
959
  gr.update(visible=False),
960
+ None,
961
  )
962
 
963
  macro_auc = run_meta.get("macro_auc")
 
974
  )
975
  + "</div>"
976
  )
977
+ loss_figure = _loss_plot(bundle.history) if bundle.history else None
978
+ return stats, gr.update(visible=True), "", gr.update(visible=True), loss_figure
979
 
980
 
981
  THEME = gr.themes.Base(primary_hue="gray", neutral_hue="gray")
 
996
  session_id_state = gr.State("")
997
  intro_step_state = gr.State(0)
998
  intro_dismissed_state = gr.BrowserState(False, storage_key="biolmnet_intro_dismissed")
999
+ nav_ids = ["nav-data", "nav-train", "nav-export", "nav-predict", "nav-results"]
1000
 
1001
  with gr.Row(elem_classes=["app-shell"]):
1002
  with gr.Column(elem_classes=["rail-col"]):
1003
  with gr.Column(elem_classes=["rail"]):
1004
+ gr.HTML(ui.brand_block_html(), elem_id="app-header")
1005
  rail_row_htmls = []
1006
  rail_row_buttons = []
1007
  with gr.Column(elem_classes=["rail-nav"]):
1008
  for index, label in enumerate(STAGE_LABELS):
1009
  with gr.Column(elem_classes=["rail-row-wrap"]):
1010
  row_html = gr.HTML(ui.rail_row_html(index + 1, label, "on" if index == 0 else "off", "Active" if index == 0 else "Locked"))
1011
+ row_button = gr.Button("", elem_id=nav_ids[index], elem_classes=["rail-row-click"])
1012
  rail_row_htmls.append(row_html)
1013
  rail_row_buttons.append(row_button)
1014
  with gr.Column(elem_classes=["rail-spacer", "rail-plate-wrap"]):
 
1022
  intro_open_button = gr.Button("Introduction", size="sm", variant="secondary", elem_classes=["btn-ghost", "intro-open-btn"])
1023
 
1024
  # ── Data & Priors ────────────────────────────────────────
1025
+ with gr.Column(visible=True, elem_id="page-data", elem_classes=["workspace-page", "data-workspace-page"]) as data_page:
1026
  with gr.Row(elem_classes=["pghd"]):
1027
  gr.HTML(
1028
  ui.title_block_html(
 
1039
  source_mode = gr.Radio(
1040
  ["BioLM-NET examples", "GitHub folder", "Upload files"],
1041
  value="BioLM-NET examples", show_label=False, container=False,
1042
+ elem_id="dataset-source", elem_classes=["seg-radio"],
1043
  )
1044
  with gr.Row(elem_classes=["srow-row", "first"]):
1045
  with gr.Column(scale=1, min_width=0):
 
1049
  gr.HTML('<div class="field-label">Repository dataset</div>')
1050
  example_dataset = gr.Dropdown(
1051
  ["BRCA", "COAD", "GBM", "scTrioseq2"], value="BRCA",
1052
+ show_label=False, container=False, elem_id="dataset-select",
1053
  )
1054
  with gr.Column(visible=False) as github_group:
1055
  github_folder = gr.Textbox(
 
1155
  "Re-validate inputs", size="sm", variant="secondary", elem_classes=["actbar-secondary-btn"]
1156
  )
1157
  prepare_button = gr.Button(
1158
+ "Build biological architecture", size="sm", variant="primary",
1159
+ elem_id="build-architecture-btn", elem_classes=["actbar-primary-btn"]
1160
  )
1161
  preparation_status = gr.HTML(visible=False)
1162
 
1163
  # ── Train Model ──────────────────────────────────────────
1164
+ with gr.Column(visible=False, elem_id="page-train", elem_classes=["workspace-page"]) as train_page:
1165
  with gr.Row(elem_classes=["pghd"]):
1166
  gr.HTML(
1167
  ui.title_block_html(
 
1177
  with gr.Column(min_width=140):
1178
  gr.HTML(ui.esc("Epochs"))
1179
  with gr.Column():
1180
+ epochs = gr.Slider(5, 200, value=Hyperparameters.epochs, step=5, show_label=False, container=False)
1181
  with gr.Row(elem_classes=["srow-row"]):
1182
  with gr.Column(min_width=140):
1183
  gr.HTML(ui.esc("Batch size"))
1184
  with gr.Column():
1185
+ batch_size = gr.Dropdown([8, 16, 32, 64, 128], value=Hyperparameters.batch_size, show_label=False, container=False)
1186
  with gr.Row(elem_classes=["srow-row"]):
1187
  with gr.Column(min_width=140):
1188
  gr.HTML(ui.esc("Learning rate"))
1189
  with gr.Column():
1190
+ learning_rate = gr.Number(value=Hyperparameters.learning_rate, minimum=0.000001, show_label=False, container=False)
1191
  with gr.Row(elem_classes=["srow-row"]):
1192
  with gr.Column(min_width=140):
1193
  gr.HTML(ui.esc("L2 weight decay"))
1194
  with gr.Column():
1195
+ weight_decay = gr.Number(value=Hyperparameters.weight_decay, minimum=0, show_label=False, container=False)
1196
  with gr.Row(elem_classes=["srow-row", "last"]):
1197
  with gr.Column(min_width=140):
1198
  gr.HTML(ui.esc("Dropout"))
 
1214
  with gr.Column(min_width=140):
1215
  gr.HTML(ui.esc("Validation fraction"))
1216
  with gr.Column():
1217
+ validation_fraction = gr.Slider(
1218
+ 0.1, 0.4, value=DEFAULT_VALIDATION_FRACTION, step=0.05, show_label=False, container=False
1219
+ )
1220
+ validation_split_note = gr.HTML(_validation_split_note(DEFAULT_VALIDATION_FRACTION))
1221
+ validation_fraction.change(
1222
+ _validation_split_note, inputs=[validation_fraction], outputs=[validation_split_note]
1223
+ )
1224
  with gr.Row(elem_classes=["srow-row"]):
1225
  with gr.Column(min_width=140):
1226
  gr.HTML(ui.esc("Optimizer"))
 
1239
  gr.HTML('<div class="mono" style="margin-bottom:9px">Training result</div>')
1240
  training_status = gr.HTML(_status("Prepare the architecture before training."))
1241
  loss_plot = gr.Plot(label="Loss by epoch", show_label=False)
1242
+ gr.HTML('<div class="mono" style="margin:20px 0 9px">Architecture defaults</div>')
1243
  gr.HTML(
1244
  "".join(
1245
  [
1246
  ui.kv_plain_row("First layer", "trainable W ⊙ M"),
1247
+ ui.kv_plain_row(
1248
+ "Activations",
1249
+ f"{Hyperparameters.biological_activation} / {Hyperparameters.projection_activation} / "
1250
+ f"{Hyperparameters.fusion_activation}",
1251
+ ),
1252
  ui.kv_plain_row("PDI weights", "binary"),
1253
  ui.kv_plain_row("PPI weights", "STRING norm."),
1254
  ui.kv_plain_row("Attention", "GenePT query"),
 
1261
  train_actionbar_note = gr.HTML('<div class="actbar-note">Set hyperparameters, then train.</div>')
1262
  with gr.Row(elem_classes=["actbar-buttons"]):
1263
  train_button = gr.Button(
1264
+ "Train BioLM-NET on ZeroGPU", size="sm", variant="primary",
1265
+ elem_id="train-btn", elem_classes=["actbar-primary-btn"]
1266
  )
1267
 
1268
  # ── Export Artifacts ─────────────────────────────────────
1269
+ with gr.Column(visible=False, elem_id="page-export", elem_classes=["workspace-page"]) as export_page:
1270
  with gr.Row(elem_classes=["pghd"]):
1271
  gr.HTML(
1272
  ui.title_block_html(
 
1294
  gr.HTML('<div class="actbar-note">Bundle written to a temp file on export · not persisted between Space restarts</div>')
1295
 
1296
  # ── Predict ──────────────────────────────────────────────
1297
+ with gr.Column(visible=False, elem_id="page-predict", elem_classes=["workspace-page"]) as predict_page:
1298
  with gr.Row(elem_classes=["pghd"]):
1299
  gr.HTML(
1300
  ui.title_block_html(
 
1358
  with gr.Row(elem_classes=["actbar-buttons"]):
1359
  predict_button = gr.Button(
1360
  "Run inference", size="sm", variant="primary", interactive=False,
1361
+ elem_id="predict-btn", elem_classes=["actbar-primary-btn"],
1362
  )
1363
  prediction_status = gr.HTML(visible=False)
1364
 
1365
  # ── Results ──────────────────────────────────────────────
1366
+ with gr.Column(visible=False, elem_id="page-results", elem_classes=["workspace-page"]) as results_page:
1367
  with gr.Row(elem_classes=["pghd"]):
1368
  gr.HTML(
1369
  ui.title_block_html(
 
1666
  ).then(
1667
  refresh_results,
1668
  inputs=[model_state, run_meta_state],
1669
+ outputs=[results_stats, results_stats_row, results_empty_note, results_content, results_loss_plot],
1670
  show_progress="hidden",
1671
  ).then(
1672
  lambda df: gr.update(value=df), inputs=[validation_predictions_state], outputs=[results_validation_table],
 
1742
  show_progress="hidden",
1743
  ).then(
1744
  refresh_results, inputs=[model_state, run_meta_state],
1745
+ outputs=[results_stats, results_stats_row, results_empty_note, results_content, results_loss_plot],
1746
  show_progress="hidden",
1747
  )
1748
 
biolmnet/training.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import copy
 
4
  import random
5
  from dataclasses import asdict, dataclass
6
  from typing import Callable
@@ -25,21 +26,36 @@ from .model import BioLMNet
25
 
26
  ProgressCallback = Callable[[float, str], None]
27
 
 
 
 
28
 
 
 
 
 
 
 
 
 
29
  @dataclass(frozen=True)
30
  class Hyperparameters:
31
- epochs: int = 50
32
- batch_size: int = 16
33
- learning_rate: float = 0.001
34
  weight_decay: float = 0.01
35
  dropout: float = 0.3
36
  projection_dim: int = 64
37
  fusion_dim: int = 12
38
- validation_fraction: float = 0.2
39
  optimizer: str = "Adam"
40
  class_weighting: bool = True
41
  early_stopping_patience: int = 12
42
  seed: int = 42
 
 
 
 
43
 
44
  def validate(self) -> None:
45
  if not 1 <= self.epochs <= 1000:
@@ -54,6 +70,11 @@ class Hyperparameters:
54
  raise ValueError("Validation fraction must be between 0.05 and 0.5.")
55
  if self.optimizer.lower() not in {"adam", "sgd"}:
56
  raise ValueError("Optimizer must be Adam or SGD.")
 
 
 
 
 
57
 
58
 
59
  @dataclass
@@ -86,6 +107,36 @@ def set_reproducible_seed(seed: int) -> None:
86
  torch.cuda.manual_seed_all(seed)
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _fit_scaler(values: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
90
  mean = values.mean(axis=0, dtype=np.float64).astype(np.float32)
91
  scale = values.std(axis=0, dtype=np.float64).astype(np.float32)
@@ -121,6 +172,9 @@ def _make_model(
121
  projection_dim=hyperparameters.projection_dim,
122
  fusion_dim=hyperparameters.fusion_dim,
123
  dropout=hyperparameters.dropout,
 
 
 
124
  )
125
 
126
 
@@ -153,18 +207,8 @@ def train(
153
  hyperparameters.validate()
154
  set_reproducible_seed(hyperparameters.seed)
155
  labels = workspace.labels
156
- classes, class_counts = np.unique(labels, return_counts=True)
157
- if class_counts.min() < 2:
158
- raise ValueError(
159
- "Each class needs at least two samples for a stratified train/validation split."
160
- )
161
-
162
- indices = np.arange(len(labels))
163
- train_index, validation_index = train_test_split(
164
- indices,
165
- test_size=hyperparameters.validation_fraction,
166
- random_state=hyperparameters.seed,
167
- stratify=labels,
168
  )
169
  gene_mean, gene_scale = _fit_scaler(
170
  workspace.gene_expression[train_index]
@@ -204,6 +248,7 @@ def train(
204
  optimizer = torch.optim.SGD(
205
  model.parameters(),
206
  lr=hyperparameters.learning_rate,
 
207
  weight_decay=hyperparameters.weight_decay,
208
  )
209
  else:
@@ -357,9 +402,9 @@ def train(
357
  "projection_dim": hyperparameters.projection_dim,
358
  "fusion_dim": hyperparameters.fusion_dim,
359
  "dropout": hyperparameters.dropout,
360
- "biological_activation": "relu",
361
- "projection_activation": "sigmoid",
362
- "fusion_activation": "tanh",
363
  },
364
  }
365
  bundle = ModelBundle(
 
1
  from __future__ import annotations
2
 
3
  import copy
4
+ import math
5
  import random
6
  from dataclasses import asdict, dataclass
7
  from typing import Callable
 
26
 
27
  ProgressCallback = Callable[[float, str], None]
28
 
29
+ # 75% training / 25% validation. The validation partition drives early
30
+ # stopping and best-checkpoint selection, so it is not an untouched test set.
31
+ DEFAULT_VALIDATION_FRACTION = 0.25
32
 
33
+ # Activations BioLMNet accepts.
34
+ ACTIVATIONS = ("relu", "sigmoid", "tanh")
35
+
36
+
37
+ # Defaults follow the Bozdag Lab grid (J. Rifat, 2026-10-17): the configuration that stayed
38
+ # stable across 8 seeds on BRCA (macro F1 0.749 +/- 0.018). The published activations
39
+ # (relu/sigmoid/tanh) collapse at this learning rate and L2; see
40
+ # diagnostics/2026-10-30-lab-hyperparameter-grid.
41
  @dataclass(frozen=True)
42
  class Hyperparameters:
43
+ epochs: int = 200
44
+ batch_size: int = 32
45
+ learning_rate: float = 0.01
46
  weight_decay: float = 0.01
47
  dropout: float = 0.3
48
  projection_dim: int = 64
49
  fusion_dim: int = 12
50
+ validation_fraction: float = DEFAULT_VALIDATION_FRACTION
51
  optimizer: str = "Adam"
52
  class_weighting: bool = True
53
  early_stopping_patience: int = 12
54
  seed: int = 42
55
+ biological_activation: str = "tanh"
56
+ projection_activation: str = "relu"
57
+ fusion_activation: str = "tanh"
58
+ sgd_momentum: float = 0.03
59
 
60
  def validate(self) -> None:
61
  if not 1 <= self.epochs <= 1000:
 
70
  raise ValueError("Validation fraction must be between 0.05 and 0.5.")
71
  if self.optimizer.lower() not in {"adam", "sgd"}:
72
  raise ValueError("Optimizer must be Adam or SGD.")
73
+ for name in ("biological_activation", "projection_activation", "fusion_activation"):
74
+ if getattr(self, name) not in ACTIVATIONS:
75
+ raise ValueError(f"{name.replace('_', ' ').capitalize()} must be one of {', '.join(ACTIVATIONS)}.")
76
+ if not 0 <= self.sgd_momentum < 1:
77
+ raise ValueError("SGD momentum must be in [0, 1).")
78
 
79
 
80
  @dataclass
 
107
  torch.cuda.manual_seed_all(seed)
108
 
109
 
110
+ def split_train_validation(
111
+ labels: np.ndarray, validation_fraction: float, seed: int
112
+ ) -> tuple[np.ndarray, np.ndarray]:
113
+ """Stratified sample-index split shared by both omics and the labels."""
114
+ classes, class_counts = np.unique(labels, return_counts=True)
115
+ if class_counts.min() < 2:
116
+ raise ValueError(
117
+ "Each class needs at least two samples for a stratified train/validation split."
118
+ )
119
+ sample_count = len(labels)
120
+ validation_count = math.ceil(validation_fraction * sample_count)
121
+ train_count = sample_count - validation_count
122
+ if min(validation_count, train_count) < len(classes):
123
+ raise ValueError(
124
+ f"A validation fraction of {validation_fraction:.2f} on {sample_count} samples "
125
+ f"gives {train_count} training and {validation_count} validation samples; "
126
+ f"each partition needs at least one sample per class ({len(classes)} classes) "
127
+ "for a stratified split. Adjust the validation fraction or add samples."
128
+ )
129
+
130
+ indices = np.arange(sample_count)
131
+ train_index, validation_index = train_test_split(
132
+ indices,
133
+ test_size=validation_fraction,
134
+ random_state=seed,
135
+ stratify=labels,
136
+ )
137
+ return train_index, validation_index
138
+
139
+
140
  def _fit_scaler(values: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
141
  mean = values.mean(axis=0, dtype=np.float64).astype(np.float32)
142
  scale = values.std(axis=0, dtype=np.float64).astype(np.float32)
 
172
  projection_dim=hyperparameters.projection_dim,
173
  fusion_dim=hyperparameters.fusion_dim,
174
  dropout=hyperparameters.dropout,
175
+ biological_activation=hyperparameters.biological_activation,
176
+ projection_activation=hyperparameters.projection_activation,
177
+ fusion_activation=hyperparameters.fusion_activation,
178
  )
179
 
180
 
 
207
  hyperparameters.validate()
208
  set_reproducible_seed(hyperparameters.seed)
209
  labels = workspace.labels
210
+ train_index, validation_index = split_train_validation(
211
+ labels, hyperparameters.validation_fraction, hyperparameters.seed
 
 
 
 
 
 
 
 
 
 
212
  )
213
  gene_mean, gene_scale = _fit_scaler(
214
  workspace.gene_expression[train_index]
 
248
  optimizer = torch.optim.SGD(
249
  model.parameters(),
250
  lr=hyperparameters.learning_rate,
251
+ momentum=hyperparameters.sgd_momentum,
252
  weight_decay=hyperparameters.weight_decay,
253
  )
254
  else:
 
402
  "projection_dim": hyperparameters.projection_dim,
403
  "fusion_dim": hyperparameters.fusion_dim,
404
  "dropout": hyperparameters.dropout,
405
+ "biological_activation": hyperparameters.biological_activation,
406
+ "projection_activation": hyperparameters.projection_activation,
407
+ "fusion_activation": hyperparameters.fusion_activation,
408
  },
409
  }
410
  bundle = ModelBundle(
biolmnet/ui/styles.py CHANGED
@@ -236,6 +236,12 @@ body, .gradio-container {
236
  flex: 1 1 auto !important;
237
  width: calc(100% - 236px) !important;
238
  }
 
 
 
 
 
 
239
 
240
  /* rail row (overlay: visible .rr under an invisible full-size button) */
241
  .rail-row-wrap { position: relative; }
@@ -424,6 +430,46 @@ body, .gradio-container {
424
  .strip.error .strip-kicker { color: var(--color-error); }
425
  .strip .strip-body { margin-top: 4px; font-size: 12.5px; line-height: 1.5; }
426
  .empty-note { padding: 14px 0; color: var(--color-muted); font-size: 13px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
  /* ── slots (one-line gr.File) ─────────────────────────────────────────── */
429
  .file-slot .file-preview { border: 1px solid var(--color-divider) !important; border-radius: var(--radius-md); background: #fff; font-size: 12.5px !important; }
 
236
  flex: 1 1 auto !important;
237
  width: calc(100% - 236px) !important;
238
  }
239
+ .workspace-page,
240
+ .workspace-page > .form {
241
+ min-height: calc(100vh - 45px) !important;
242
+ display: flex !important;
243
+ flex-direction: column !important;
244
+ }
245
 
246
  /* rail row (overlay: visible .rr under an invisible full-size button) */
247
  .rail-row-wrap { position: relative; }
 
430
  .strip.error .strip-kicker { color: var(--color-error); }
431
  .strip .strip-body { margin-top: 4px; font-size: 12.5px; line-height: 1.5; }
432
  .empty-note { padding: 14px 0; color: var(--color-muted); font-size: 13px; }
433
+ .field-hint { margin-top: 6px; color: var(--color-muted); font-size: 12px; line-height: 1.4; }
434
+
435
+ @media (min-width: 901px) and (max-height: 820px) {
436
+ .topbar {
437
+ padding-top: 10px;
438
+ padding-bottom: 10px;
439
+ }
440
+ .pghd {
441
+ padding-top: 18px;
442
+ }
443
+ .pghd h2 {
444
+ font-size: 31px;
445
+ }
446
+ .pghd .desc {
447
+ margin-top: 4px;
448
+ line-height: 1.35;
449
+ }
450
+ .stage-grid {
451
+ padding-top: 16px !important;
452
+ gap: 22px !important;
453
+ }
454
+ .data-workspace-page .section-heading-row {
455
+ margin-bottom: 8px !important;
456
+ }
457
+ .data-workspace-page .sect-spaced {
458
+ margin-top: 18px !important;
459
+ }
460
+ .data-workspace-page .srow-row {
461
+ min-height: 42px;
462
+ }
463
+ .data-workspace-page .dataframe,
464
+ .data-workspace-page .table-wrap {
465
+ max-height: 118px !important;
466
+ overflow: auto !important;
467
+ }
468
+ .actbar-row {
469
+ padding-top: 11px !important;
470
+ padding-bottom: 11px !important;
471
+ }
472
+ }
473
 
474
  /* ── slots (one-line gr.File) ─────────────────────────────────────────── */
475
  .file-slot .file-preview { border: 1px solid var(--color-divider) !important; border-radius: var(--radius-md); background: #fff; font-size: 12.5px !important; }
tests/test_core.py CHANGED
@@ -1,9 +1,11 @@
1
  from __future__ import annotations
2
 
3
  import pickle
 
4
 
5
  import numpy as np
6
  import pandas as pd
 
7
  import torch
8
 
9
  from biolmnet.artifacts import load_bundle, save_bundle
@@ -15,7 +17,13 @@ from biolmnet.data import (
15
  deterministic_gene_embeddings,
16
  )
17
  from biolmnet.model import BioMaskedLinear, BioLMNet
18
- from biolmnet.training import Hyperparameters, predict, train
 
 
 
 
 
 
19
 
20
 
21
  def test_biological_mask_uses_pdi_and_undirected_ppi() -> None:
@@ -232,3 +240,217 @@ def test_zerogpu_duration_estimator_is_bounded_and_scales() -> None:
232
  long = estimate_training_duration(common[0], 200, *common[1:])
233
  assert 30 <= short <= 300
234
  assert short <= long <= 300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import pickle
4
+ from dataclasses import replace
5
 
6
  import numpy as np
7
  import pandas as pd
8
+ import pytest
9
  import torch
10
 
11
  from biolmnet.artifacts import load_bundle, save_bundle
 
17
  deterministic_gene_embeddings,
18
  )
19
  from biolmnet.model import BioMaskedLinear, BioLMNet
20
+ from biolmnet.training import (
21
+ DEFAULT_VALIDATION_FRACTION,
22
+ Hyperparameters,
23
+ predict,
24
+ split_train_validation,
25
+ train,
26
+ )
27
 
28
 
29
  def test_biological_mask_uses_pdi_and_undirected_ppi() -> None:
 
240
  long = estimate_training_duration(common[0], 200, *common[1:])
241
  assert 30 <= short <= 300
242
  assert short <= long <= 300
243
+
244
+
245
+ _FAST = Hyperparameters(
246
+ epochs=3,
247
+ batch_size=8,
248
+ projection_dim=4,
249
+ fusion_dim=3,
250
+ dropout=0.0,
251
+ early_stopping_patience=3,
252
+ )
253
+
254
+
255
+ def test_default_split_is_75_training_25_validation() -> None:
256
+ assert DEFAULT_VALIDATION_FRACTION == 0.25
257
+ assert Hyperparameters().validation_fraction == 0.25
258
+ labels = np.repeat(np.array([0, 1]), 50)
259
+ train_index, validation_index = split_train_validation(
260
+ labels, Hyperparameters().validation_fraction, Hyperparameters().seed
261
+ )
262
+ assert len(train_index) == 75
263
+ assert len(validation_index) == 25
264
+
265
+
266
+ def test_split_partitions_every_sample_exactly_once() -> None:
267
+ labels = np.repeat(np.array([0, 1, 2]), [40, 25, 18])
268
+ train_index, validation_index = split_train_validation(labels, 0.25, 42)
269
+ assert len(train_index) + len(validation_index) == len(labels)
270
+ assert set(train_index).isdisjoint(validation_index)
271
+ assert sorted(np.concatenate([train_index, validation_index])) == list(range(len(labels)))
272
+
273
+
274
+ def test_split_preserves_class_proportions() -> None:
275
+ labels = np.repeat(np.array([0, 1, 2]), [120, 60, 20]) # 60 / 30 / 10
276
+ train_index, validation_index = split_train_validation(labels, 0.25, 42)
277
+ expected = np.array([0.6, 0.3, 0.1])
278
+ for partition in (train_index, validation_index):
279
+ observed = np.bincount(labels[partition], minlength=3) / len(partition)
280
+ np.testing.assert_allclose(observed, expected, atol=0.02)
281
+
282
+
283
+ def test_split_is_reproducible_for_a_fixed_seed() -> None:
284
+ labels = np.repeat(np.array([0, 1]), [30, 34])
285
+ first = split_train_validation(labels, 0.25, 42)
286
+ second = split_train_validation(labels, 0.25, 42)
287
+ other_seed = split_train_validation(labels, 0.25, 7)
288
+ np.testing.assert_array_equal(first[0], second[0])
289
+ np.testing.assert_array_equal(first[1], second[1])
290
+ assert set(first[1]) != set(other_seed[1])
291
+
292
+
293
+ def test_split_rejects_partitions_too_small_to_stratify() -> None:
294
+ with pytest.raises(ValueError, match="at least two samples"):
295
+ split_train_validation(np.array([0, 0, 0, 1]), 0.25, 42)
296
+ # 4 classes x 2 samples: 25% leaves 2 validation samples for 4 classes.
297
+ with pytest.raises(ValueError, match="one sample per class"):
298
+ split_train_validation(np.repeat(np.arange(4), 2), 0.25, 42)
299
+
300
+
301
+ def test_training_uses_paired_rows_and_train_only_scaler() -> None:
302
+ workspace = _tiny_workspace()
303
+ hyperparameters = replace(_FAST, validation_fraction=0.25)
304
+ train_index, validation_index = split_train_validation(
305
+ workspace.labels, hyperparameters.validation_fraction, hyperparameters.seed
306
+ )
307
+ # Shift only the validation rows: a scaler fitted on all samples would move.
308
+ workspace.gene_expression[validation_index] += 100.0
309
+ workspace.dna_methylation[validation_index] -= 100.0
310
+
311
+ result = train(workspace, hyperparameters)
312
+ bundle = result.bundle
313
+
314
+ assert bundle.metrics["training_samples"] == 36
315
+ assert bundle.metrics["validation_samples"] == 12
316
+ np.testing.assert_array_equal(
317
+ result.validation_predictions["sample_row"].to_numpy(), validation_index
318
+ )
319
+ expected_labels = [workspace.label_names[value] for value in workspace.labels[validation_index]]
320
+ assert result.validation_predictions["observed"].tolist() == expected_labels
321
+
322
+ # Both omics scalers come from the same training rows only.
323
+ np.testing.assert_allclose(
324
+ bundle.gene_mean, workspace.gene_expression[train_index].mean(axis=0), rtol=1e-5
325
+ )
326
+ np.testing.assert_allclose(
327
+ bundle.gene_scale, workspace.gene_expression[train_index].std(axis=0), rtol=1e-5
328
+ )
329
+ np.testing.assert_allclose(
330
+ bundle.dna_mean, workspace.dna_methylation[train_index].mean(axis=0), rtol=1e-5
331
+ )
332
+ np.testing.assert_allclose(
333
+ bundle.dna_scale, workspace.dna_methylation[train_index].std(axis=0), rtol=1e-5
334
+ )
335
+
336
+ # Validation rows are transformed with that training-fitted scaler: the
337
+ # stored validation probabilities match predict(), which uses the bundle's scaler.
338
+ rescored = predict(
339
+ pd.DataFrame(workspace.gene_expression[validation_index], columns=bundle.gene_features),
340
+ pd.DataFrame(workspace.dna_methylation[validation_index], columns=bundle.dna_features),
341
+ bundle,
342
+ )
343
+ probability_columns = [f"P({label})" for label in workspace.label_names]
344
+ np.testing.assert_allclose(
345
+ result.validation_predictions[probability_columns].to_numpy(),
346
+ rescored[probability_columns].to_numpy(),
347
+ atol=1e-5,
348
+ )
349
+
350
+
351
+ def test_class_weights_use_training_labels_only(monkeypatch) -> None:
352
+ import biolmnet.training as training
353
+
354
+ captured = {}
355
+ original = training.nn.CrossEntropyLoss
356
+
357
+ def spy(*args, weight=None, **kwargs):
358
+ captured["weight"] = None if weight is None else weight.cpu().numpy()
359
+ return original(*args, weight=weight, **kwargs)
360
+
361
+ monkeypatch.setattr(training.nn, "CrossEntropyLoss", spy)
362
+ workspace = _tiny_workspace() # 24 control / 24 case: all-sample weights are [1, 1].
363
+ # Force a lopsided split so training-only weights clearly differ from all-sample ones.
364
+ train_index = np.concatenate([np.arange(0, 22), np.arange(24, 30)])
365
+ validation_index = np.setdiff1d(np.arange(48), train_index)
366
+ monkeypatch.setattr(
367
+ training, "split_train_validation", lambda *args: (train_index, validation_index)
368
+ )
369
+ train(workspace, _FAST)
370
+
371
+ counts = np.bincount(workspace.labels[train_index], minlength=2).astype(np.float32)
372
+ assert counts.tolist() == [22.0, 6.0]
373
+ np.testing.assert_allclose(captured["weight"], len(train_index) / (2 * counts), rtol=1e-6)
374
+
375
+
376
+ def test_results_page_receives_training_history_plot() -> None:
377
+ from app import refresh_results
378
+
379
+ assert refresh_results(None, {})[-1] is None
380
+
381
+ result = train(_tiny_workspace(), _FAST)
382
+ figure = refresh_results(result.bundle, {})[-1]
383
+ traces = {trace.name: trace for trace in figure.data}
384
+ assert set(traces) == {"Training", "Validation"}
385
+ history = result.bundle.history
386
+ assert list(traces["Training"].y) == [row["training_loss"] for row in history]
387
+ assert list(traces["Validation"].y) == [row["validation_loss"] for row in history]
388
+
389
+ # Artifacts saved without history still render the rest of the page.
390
+ result.bundle.history = []
391
+ assert refresh_results(result.bundle, {})[-1] is None
392
+
393
+
394
+ def test_activation_choices_reach_model_config_and_artifact(tmp_path) -> None:
395
+ hyperparameters = replace(
396
+ _FAST,
397
+ biological_activation="tanh",
398
+ projection_activation="relu",
399
+ fusion_activation="sigmoid",
400
+ )
401
+ result = train(_tiny_workspace(), hyperparameters)
402
+ branch = result.bundle.model.gene_branch
403
+ assert isinstance(branch.biological_activation, torch.nn.Tanh)
404
+ assert isinstance(branch.projection_activation, torch.nn.ReLU)
405
+ assert isinstance(result.bundle.model.fusion_activation, torch.nn.Sigmoid)
406
+ assert result.bundle.config["architecture"]["biological_activation"] == "tanh"
407
+
408
+ restored = load_bundle(save_bundle(result.bundle, tmp_path / "model.zip"))
409
+ assert isinstance(restored.model.gene_branch.biological_activation, torch.nn.Tanh)
410
+ assert isinstance(restored.model.fusion_activation, torch.nn.Sigmoid)
411
+
412
+
413
+ def test_defaults_follow_lab_grid_configuration() -> None:
414
+ defaults = Hyperparameters()
415
+ assert (defaults.optimizer, defaults.learning_rate, defaults.weight_decay) == ("Adam", 0.01, 0.01)
416
+ assert (defaults.batch_size, defaults.projection_dim, defaults.dropout, defaults.epochs) == (32, 64, 0.3, 200)
417
+ assert (defaults.biological_activation, defaults.projection_activation, defaults.fusion_activation) == (
418
+ "tanh",
419
+ "relu",
420
+ "tanh",
421
+ )
422
+ assert defaults.sgd_momentum == 0.03
423
+
424
+
425
+ def test_auto_genept_uses_lab_recommended_single_cell_file() -> None:
426
+ from app import GENEPT_OPTIONS, _resolve_embedding_file
427
+
428
+ auto = next(label for label, value in GENEPT_OPTIONS.items() if value == "auto")
429
+ for dataset in ("BRCA", "COAD", "GBM", "scTrioseq2"):
430
+ assert _resolve_embedding_file(auto, "BioLM-NET examples", dataset) == (
431
+ "embedding_associations_cell_type_tissue_drug_pathway_openai_large.parquet"
432
+ )
433
+
434
+
435
+ def test_sgd_momentum_reaches_optimizer(monkeypatch) -> None:
436
+ import biolmnet.training as training
437
+
438
+ captured = {}
439
+ original = training.torch.optim.SGD
440
+
441
+ def spy(params, **kwargs):
442
+ captured.update(kwargs)
443
+ return original(params, **kwargs)
444
+
445
+ monkeypatch.setattr(training.torch.optim, "SGD", spy)
446
+ train(_tiny_workspace(), replace(_FAST, optimizer="SGD", learning_rate=0.01, sgd_momentum=0.03))
447
+ assert captured["momentum"] == 0.03
448
+
449
+
450
+ @pytest.mark.parametrize(
451
+ "field, value",
452
+ [("biological_activation", "softmax"), ("fusion_activation", "gelu"), ("sgd_momentum", 1.0)],
453
+ )
454
+ def test_invalid_activation_or_momentum_is_rejected(field, value) -> None:
455
+ with pytest.raises(ValueError):
456
+ replace(Hyperparameters(), **{field: value}).validate()