Spaces:
Running on Zero
Fix BRCA training collapse with lab-grid defaults
Browse filesTraining 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).
- app.py +49 -25
- biolmnet/training.py +64 -19
- biolmnet/ui/styles.py +46 -0
- tests/test_core.py +223 -1
|
@@ -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 —
|
| 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 |
-
|
| 105 |
-
|
| 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 |
-
|
|
|
|
| 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",
|
|
|
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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",
|
|
|
|
| 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 |
|
|
@@ -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 =
|
| 32 |
-
batch_size: int =
|
| 33 |
-
learning_rate: float = 0.
|
| 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 =
|
| 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 |
-
|
| 157 |
-
|
| 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":
|
| 361 |
-
"projection_activation":
|
| 362 |
-
"fusion_activation":
|
| 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(
|
|
@@ -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; }
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|