bozdaglab commited on
Commit
31376a7
·
verified ·
1 Parent(s): abe7732

Add ZeroGPU-enabled BioLM-NET workbench

Browse files
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .venv/
4
+ *.py[cod]
5
+ *.zip
6
+ *.csv
7
+ !tests/fixtures/*.csv
LICENSE ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Creative Commons Attribution-NonCommercial 4.0 International Public License
2
+
3
+ By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
4
+
5
+ Section 1 – Definitions.
6
+
7
+ Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
8
+ Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
9
+ Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
10
+ Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
11
+ Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
12
+ Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
13
+ Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
14
+ Licensor means the individual(s) or entity(ies) granting rights under this Public License.
15
+ NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
16
+ Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
17
+ Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
18
+ You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
19
+
20
+ Section 2 – Scope.
21
+
22
+ License grant.
23
+ Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
24
+ reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
25
+ produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
26
+ Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
27
+ Term. The term of this Public License is specified in Section 6(a).
28
+ Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
29
+ Downstream recipients.
30
+ Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
31
+ No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
32
+ No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
33
+
34
+ Other rights.
35
+ Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
36
+ Patent and trademark rights are not licensed under this Public License.
37
+ To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
38
+
39
+ Section 3 – License Conditions.
40
+
41
+ Your exercise of the Licensed Rights is expressly made subject to the following conditions.
42
+
43
+ Attribution.
44
+
45
+ If You Share the Licensed Material (including in modified form), You must:
46
+ retain the following if it is supplied by the Licensor with the Licensed Material:
47
+ identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
48
+ a copyright notice;
49
+ a notice that refers to this Public License;
50
+ a notice that refers to the disclaimer of warranties;
51
+ a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
52
+ indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
53
+ indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
54
+ You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
55
+ If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
56
+ If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
57
+
58
+ Section 4 – Sui Generis Database Rights.
59
+
60
+ Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
61
+
62
+ for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
63
+ if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
64
+ You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
65
+
66
+ For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
67
+
68
+ Section 5 – Disclaimer of Warranties and Limitation of Liability.
69
+
70
+ Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
71
+ To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
72
+
73
+ The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
74
+
75
+ Section 6 – Term and Termination.
76
+
77
+ Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
78
+ automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
79
+ upon express reinstatement by the Licensor.
80
+ For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
81
+ For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
82
+ Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
83
+
84
+ Section 7 – Other Terms and Conditions.
85
+
86
+ The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
87
+ Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
88
+
89
+ Section 8 – Interpretation.
90
+
91
+ For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
92
+ To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
93
+ No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
94
+ Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
README.md CHANGED
@@ -1,13 +1,127 @@
1
  ---
2
- title: BioLM NET Workbench
3
- emoji: 😻
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
 
 
 
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: BioLM-NET Workbench
3
+ emoji: 🧬
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
 
 
7
  app_file: app.py
8
+ python_version: "3.12"
9
+ suggested_hardware: zero-a10g
10
+ license: cc-by-nc-4.0
11
  pinned: false
12
  ---
13
 
14
+ # BioLM-NET Workbench
15
+
16
+ BioLM-NET Workbench is a guided Hugging Face Spaces interface for assembling,
17
+ training, exporting, and applying the interpretable dual-omics architecture
18
+ described by Rifat et al.
19
+
20
+ The interface supports:
21
+
22
+ - the BRCA, COAD, GBM, and scTrioseq2 datasets from
23
+ [`bozdaglab/BioLM-NET`](https://github.com/bozdaglab/BioLM-NET);
24
+ - a GitHub dataset folder containing the standard BioLM-NET filenames;
25
+ - user uploads for paired gene-expression and DNA-methylation matrices, labels,
26
+ and gene-to-pathway mappings;
27
+ - automatic retrieval of the upstream `PDI.csv` and `PPI.csv`, with optional
28
+ custom overrides;
29
+ - paper-defined trainable biological masking (`W_masked = W ⊙ M`);
30
+ - GenePT-guided, membership-masked pathway attention;
31
+ - on-demand ZeroGPU allocation only while model training runs;
32
+ - stratified validation, balanced loss, downloadable model artifacts, and a
33
+ separate prediction workflow.
34
+
35
+ ## Data contract
36
+
37
+ Samples are rows. Omics features are columns named with HGNC gene symbols.
38
+ Paired omics files and the label file must have the same row order and count.
39
+
40
+ | File | Required columns / orientation |
41
+ | --- | --- |
42
+ | `Gene_Expression.csv` | samples × gene-symbol columns |
43
+ | `DNA_Methylation.csv` | samples × gene-symbol columns |
44
+ | `label.csv` | exactly one label column |
45
+ | `PDI.csv` | `TF`, `Target` |
46
+ | `PPI.csv` | `protein1`, `protein2`, `combined_score` |
47
+ | gene/pathway mapping | `SYMBOL`, `PathwayID` |
48
+
49
+ For a GitHub folder, the app expects:
50
+
51
+ ```text
52
+ Gene_Expression.csv
53
+ DNA_Methylation.csv
54
+ label.csv
55
+ ge_target_to_KEGG_significant.csv
56
+ dna_target_to_KEGG_significant.csv
57
+ ```
58
+
59
+ If pathway files are already the significant results of an enrichment analysis,
60
+ leave the checkbox enabled. For a complete gene/pathway annotation catalog,
61
+ disable it; the app will run over-representation testing and retain pathways
62
+ with Benjamini-Hochberg adjusted p-value below 0.05.
63
+
64
+ ## Architecture choices
65
+
66
+ The implementation follows the paper's mathematical description:
67
+
68
+ 1. PDI edges are binary TF-to-target connections.
69
+ 2. STRING PPI scores are normalized, filtered above 0.7, and restricted to the
70
+ top score decile. Because PPI is undirected, an input gene can connect to its
71
+ partner from either endpoint.
72
+ 3. The PDI/PPI matrix masks the trainable first-layer weights directly.
73
+ 4. Only hidden genes represented by the selected GenePT file are retained.
74
+ 5. Gene-to-pathway attention scores are masked to actual memberships before the
75
+ softmax; nonmember genes cannot receive pathway attention.
76
+ 6. Gene-expression and DNA-methylation pathway projections are fused for
77
+ multiclass prediction.
78
+
79
+ Training scalers are fit only on the training partition and are included in the
80
+ exported artifact. Model tensors use `safetensors`; the artifact loader reads
81
+ only the four expected files and does not unpickle user content.
82
+
83
+ ## Run locally
84
+
85
+ ```bash
86
+ python -m venv .venv
87
+ source .venv/bin/activate
88
+ pip install -r requirements-local.txt
89
+ python app.py
90
+ ```
91
+
92
+ For local development, install `requirements-local.txt` instead. Hugging Face's
93
+ Gradio runtime supplies the platform-managed `spaces` package automatically;
94
+ the local requirements file installs its no-op implementation so the same app
95
+ also runs on CPU or a conventional GPU.
96
+
97
+ ## Deploy to Hugging Face
98
+
99
+ Create a Gradio Space, push every file in this folder to the Space repository,
100
+ and select **ZeroGPU** in **Settings → Hardware**. The Space runtime installs
101
+ `requirements.txt` and starts `app.py`. The training callback uses a dynamic
102
+ 30–300 second reservation based on dataset size, biological graph size, batch
103
+ size, and epochs. Data preparation and prediction remain on CPU and do not
104
+ consume GPU quota.
105
+
106
+ ZeroGPU users have tier-specific daily quotas and may queue during busy periods.
107
+ The epoch control is capped at 200 so paper-scale runs can fit within a single
108
+ free-tier reservation when the workload permits. If a cohort cannot finish in
109
+ five minutes, reduce the epochs or switch the Space to dedicated hardware.
110
+
111
+ ## Scientific and licensing note
112
+
113
+ This is research software, not a clinical decision system. Users remain
114
+ responsible for cohort design, preprocessing, leakage checks, external
115
+ validation, and interpretation.
116
+
117
+ BioLM-NET source and datasets are provided by the Bozdag Lab under
118
+ [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). Cite:
119
+
120
+ > Rifat, J. I. M. et al. “BioLM-NET: an interpretable deep learning model
121
+ > combining prior biological knowledge and contextual LLM gene embeddings on
122
+ > multi-omics data to predict disease.” *Pacific Symposium on Biocomputing
123
+ > 2026* (2025).
124
+
125
+ GenePT embeddings are retrieved from
126
+ [`honicky/genept-composable-embeddings`](https://huggingface.co/honicky/genept-composable-embeddings);
127
+ review that repository's license and terms for your intended use.
app.py ADDED
@@ -0,0 +1,984 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import math
5
+ import tempfile
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ import spaces
10
+ import gradio as gr
11
+ import pandas as pd
12
+ import plotly.express as px
13
+ import plotly.graph_objects as go
14
+
15
+ from biolmnet.artifacts import load_bundle, save_bundle
16
+ from biolmnet.data import (
17
+ PreparedWorkspace,
18
+ attach_embeddings_and_pathways,
19
+ build_biological_mask,
20
+ github_dataset_sources,
21
+ load_genept_embeddings,
22
+ read_csv,
23
+ upstream_example_sources,
24
+ upstream_interaction_sources,
25
+ validate_and_align_omics,
26
+ )
27
+ from biolmnet.training import (
28
+ Hyperparameters,
29
+ ModelBundle,
30
+ pathway_importance,
31
+ predict,
32
+ train,
33
+ )
34
+
35
+
36
+ GENEPT_OPTIONS = {
37
+ "Auto — bulk or single-cell based on source": "auto",
38
+ "Bulk · large-3 context": "embedding_original_large_3.parquet",
39
+ "Bulk · original ada-text context": "embedding_original_ada_text.parquet",
40
+ "Single-cell · cell type, tissue, drug & pathway": (
41
+ "embedding_associations_cell_type_tissue_drug_pathway_openai_large.parquet"
42
+ ),
43
+ "Single-cell · age, cell type, drugs & pathways": (
44
+ "embedding_associations_age_cell_type_drugs_pathways_openai_large.parquet"
45
+ ),
46
+ }
47
+
48
+
49
+ CSS = """
50
+ :root {
51
+ --ink: #102824;
52
+ --muted: #60726e;
53
+ --line: #d7e2de;
54
+ --mint: #e8f4ee;
55
+ --leaf: #167c5a;
56
+ --leaf-dark: #0e5b43;
57
+ --amber: #e5a63c;
58
+ }
59
+ .gradio-container {
60
+ max-width: 1260px !important;
61
+ margin: 0 auto !important;
62
+ background:
63
+ radial-gradient(circle at 92% 4%, rgba(209, 235, 224, .7), transparent 26rem),
64
+ #f8fbf9 !important;
65
+ color: var(--ink) !important;
66
+ }
67
+ .biolm-hero {
68
+ position: relative;
69
+ overflow: hidden;
70
+ padding: 34px 36px 30px;
71
+ margin: 12px 0 18px;
72
+ border: 1px solid #cfe0d9;
73
+ border-radius: 24px;
74
+ background: linear-gradient(135deg, #0b2f28 0%, #124b3d 70%, #17694f 100%);
75
+ box-shadow: 0 18px 50px rgba(19, 63, 52, .12);
76
+ }
77
+ .biolm-hero::after {
78
+ content: "";
79
+ position: absolute;
80
+ width: 250px;
81
+ height: 250px;
82
+ right: -60px;
83
+ top: -90px;
84
+ border: 1px solid rgba(255,255,255,.2);
85
+ border-radius: 50%;
86
+ box-shadow: 0 0 0 34px rgba(255,255,255,.035), 0 0 0 68px rgba(255,255,255,.025);
87
+ }
88
+ .eyebrow {
89
+ color: #a9e2ca;
90
+ font: 700 12px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
91
+ letter-spacing: .14em;
92
+ text-transform: uppercase;
93
+ }
94
+ .biolm-hero h1 {
95
+ color: white;
96
+ font-size: clamp(34px, 5vw, 58px);
97
+ line-height: .98;
98
+ letter-spacing: -.045em;
99
+ margin: 12px 0 14px;
100
+ }
101
+ .biolm-hero p {
102
+ max-width: 780px;
103
+ color: #d6e8e1;
104
+ font-size: 17px;
105
+ line-height: 1.55;
106
+ margin: 0;
107
+ }
108
+ .hero-meta {
109
+ display: flex;
110
+ gap: 10px;
111
+ flex-wrap: wrap;
112
+ margin-top: 22px;
113
+ }
114
+ .hero-chip {
115
+ color: #e9f7f1;
116
+ border: 1px solid rgba(255,255,255,.23);
117
+ background: rgba(255,255,255,.07);
118
+ border-radius: 999px;
119
+ padding: 7px 11px;
120
+ font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
121
+ }
122
+ .phase-card {
123
+ border: 1px solid var(--line) !important;
124
+ border-radius: 18px !important;
125
+ background: rgba(255,255,255,.86) !important;
126
+ box-shadow: 0 9px 26px rgba(27, 68, 57, .05) !important;
127
+ }
128
+ .phase-intro {
129
+ border-left: 3px solid var(--leaf);
130
+ padding: 2px 0 2px 15px;
131
+ color: var(--muted);
132
+ }
133
+ .phase-intro strong { color: var(--ink); }
134
+ .status-box {
135
+ border-radius: 15px;
136
+ padding: 14px 16px;
137
+ background: var(--mint);
138
+ border: 1px solid #cbe3d8;
139
+ color: #254d41;
140
+ }
141
+ .error-box {
142
+ border-radius: 15px;
143
+ padding: 14px 16px;
144
+ background: #fff1ee;
145
+ border: 1px solid #f0cac2;
146
+ color: #793b31;
147
+ }
148
+ .metric-grid {
149
+ display: grid;
150
+ grid-template-columns: repeat(4, minmax(0,1fr));
151
+ gap: 10px;
152
+ }
153
+ .metric {
154
+ padding: 13px 14px;
155
+ border: 1px solid #d4e2dd;
156
+ border-radius: 14px;
157
+ background: white;
158
+ }
159
+ .metric span {
160
+ display:block;
161
+ color: var(--muted);
162
+ font-size: 11px;
163
+ font-weight: 700;
164
+ letter-spacing: .07em;
165
+ text-transform: uppercase;
166
+ }
167
+ .metric b {
168
+ display:block;
169
+ margin-top: 3px;
170
+ color: var(--ink);
171
+ font-size: 22px;
172
+ }
173
+ button.primary {
174
+ background: var(--leaf) !important;
175
+ border-color: var(--leaf) !important;
176
+ }
177
+ button.primary:hover { background: var(--leaf-dark) !important; }
178
+ .footnote {
179
+ color: #6d7e79;
180
+ font-size: 12px;
181
+ line-height: 1.55;
182
+ }
183
+ @media (max-width: 720px) {
184
+ .biolm-hero { padding: 26px 22px; border-radius: 18px; }
185
+ .metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
186
+ }
187
+ """
188
+
189
+
190
+ def _status(message: str, error: bool = False) -> str:
191
+ class_name = "error-box" if error else "status-box"
192
+ return f'<div class="{class_name}">{html.escape(message)}</div>'
193
+
194
+
195
+ def _source_visibility(mode: str):
196
+ return (
197
+ gr.update(visible=mode == "BioLM-NET examples"),
198
+ gr.update(visible=mode == "GitHub folder"),
199
+ gr.update(visible=mode == "Upload files"),
200
+ )
201
+
202
+
203
+ @lru_cache(maxsize=1)
204
+ def _upstream_interactions() -> tuple[pd.DataFrame, pd.DataFrame]:
205
+ pdi_url, ppi_url = upstream_interaction_sources()
206
+ return read_csv(pdi_url), read_csv(ppi_url)
207
+
208
+
209
+ def _read_required_upload(path: str | None, label: str) -> pd.DataFrame:
210
+ if not path:
211
+ raise ValueError(f"Upload {label}.")
212
+ return read_csv(path)
213
+
214
+
215
+ def _resolve_embedding_file(
216
+ option: str, source_mode: str, example_dataset: str
217
+ ) -> str:
218
+ selected = GENEPT_OPTIONS[option]
219
+ if selected != "auto":
220
+ return selected
221
+ if source_mode == "BioLM-NET examples" and example_dataset == "scTrioseq2":
222
+ return (
223
+ "embedding_associations_cell_type_tissue_drug_pathway_"
224
+ "openai_large.parquet"
225
+ )
226
+ return "embedding_original_large_3.parquet"
227
+
228
+
229
+ def prepare_workspace(
230
+ source_mode: str,
231
+ example_dataset: str,
232
+ github_folder: str,
233
+ uploaded_gene: str | None,
234
+ uploaded_dna: str | None,
235
+ uploaded_labels: str | None,
236
+ uploaded_gene_pathway: str | None,
237
+ uploaded_dna_pathway: str | None,
238
+ uploaded_pdi: str | None,
239
+ uploaded_ppi: str | None,
240
+ pathway_files_are_significant: bool,
241
+ embedding_option: str,
242
+ progress=gr.Progress(track_tqdm=False),
243
+ ):
244
+ try:
245
+ progress(0.03, desc="Reading omics data")
246
+ if source_mode == "BioLM-NET examples":
247
+ sources = upstream_example_sources(example_dataset)
248
+ frames = {name: read_csv(url) for name, url in sources.items()}
249
+ source_name = f"BioLM-NET / {example_dataset}"
250
+ precomputed_significant = True
251
+ allow_preset_trim = True
252
+ elif source_mode == "GitHub folder":
253
+ if not github_folder.strip():
254
+ raise ValueError("Enter a GitHub dataset folder URL.")
255
+ sources = github_dataset_sources(github_folder)
256
+ frames = {name: read_csv(url) for name, url in sources.items()}
257
+ source_name = github_folder.strip()
258
+ precomputed_significant = pathway_files_are_significant
259
+ allow_preset_trim = False
260
+ else:
261
+ frames = {
262
+ "gene": _read_required_upload(
263
+ uploaded_gene, "Gene_Expression.csv"
264
+ ),
265
+ "dna": _read_required_upload(
266
+ uploaded_dna, "DNA_Methylation.csv"
267
+ ),
268
+ "labels": _read_required_upload(
269
+ uploaded_labels, "label.csv"
270
+ ),
271
+ "gene_pathways": _read_required_upload(
272
+ uploaded_gene_pathway,
273
+ "the gene-expression pathway mapping CSV",
274
+ ),
275
+ "dna_pathways": _read_required_upload(
276
+ uploaded_dna_pathway,
277
+ "the DNA-methylation pathway mapping CSV",
278
+ ),
279
+ }
280
+ source_name = "Uploaded dataset"
281
+ precomputed_significant = pathway_files_are_significant
282
+ allow_preset_trim = False
283
+
284
+ (
285
+ gene_frame,
286
+ dna_frame,
287
+ labels,
288
+ label_names,
289
+ warnings,
290
+ ) = validate_and_align_omics(
291
+ frames["gene"],
292
+ frames["dna"],
293
+ frames["labels"],
294
+ allow_preset_trim=allow_preset_trim,
295
+ )
296
+
297
+ progress(0.18, desc="Loading PDI and PPI priors")
298
+ if uploaded_pdi or uploaded_ppi:
299
+ if not uploaded_pdi or not uploaded_ppi:
300
+ raise ValueError(
301
+ "To override the repository priors, upload both PDI and PPI files."
302
+ )
303
+ pdi_frame = read_csv(uploaded_pdi)
304
+ ppi_frame = read_csv(uploaded_ppi)
305
+ else:
306
+ pdi_frame, ppi_frame = _upstream_interactions()
307
+
308
+ progress(0.38, desc="Constructing sparse biological masks")
309
+ gene_branch = build_biological_mask(
310
+ list(gene_frame.columns), pdi_frame, ppi_frame
311
+ )
312
+ dna_branch = build_biological_mask(
313
+ list(dna_frame.columns), pdi_frame, ppi_frame
314
+ )
315
+
316
+ embedding_file = _resolve_embedding_file(
317
+ embedding_option, source_mode, example_dataset
318
+ )
319
+ progress(0.53, desc="Retrieving GenePT embeddings")
320
+ embeddings = load_genept_embeddings(embedding_file)
321
+
322
+ progress(0.72, desc="Building enriched pathway connections")
323
+ gene_enrichment = attach_embeddings_and_pathways(
324
+ gene_branch,
325
+ embeddings,
326
+ frames["gene_pathways"],
327
+ precomputed_significant=precomputed_significant,
328
+ )
329
+ dna_enrichment = attach_embeddings_and_pathways(
330
+ dna_branch,
331
+ embeddings,
332
+ frames["dna_pathways"],
333
+ precomputed_significant=precomputed_significant,
334
+ )
335
+ workspace = PreparedWorkspace(
336
+ gene_expression=gene_frame.to_numpy(dtype="float32"),
337
+ dna_methylation=dna_frame.to_numpy(dtype="float32"),
338
+ labels=labels,
339
+ label_names=label_names,
340
+ gene_branch=gene_branch,
341
+ dna_branch=dna_branch,
342
+ source_name=source_name,
343
+ warnings=warnings,
344
+ )
345
+
346
+ architecture = pd.DataFrame(
347
+ [
348
+ {
349
+ "branch": "Gene expression",
350
+ "samples": len(gene_frame),
351
+ "input genes": len(gene_branch.input_genes),
352
+ "PDI edges": gene_branch.pdi_edges,
353
+ "PPI edges": gene_branch.ppi_edges,
354
+ "hidden genes": len(gene_branch.hidden_genes),
355
+ "pathways": len(gene_branch.pathways),
356
+ "mask density": (
357
+ gene_branch.biological_mask.astype(bool).mean()
358
+ ),
359
+ },
360
+ {
361
+ "branch": "DNA methylation",
362
+ "samples": len(dna_frame),
363
+ "input genes": len(dna_branch.input_genes),
364
+ "PDI edges": dna_branch.pdi_edges,
365
+ "PPI edges": dna_branch.ppi_edges,
366
+ "hidden genes": len(dna_branch.hidden_genes),
367
+ "pathways": len(dna_branch.pathways),
368
+ "mask density": (
369
+ dna_branch.biological_mask.astype(bool).mean()
370
+ ),
371
+ },
372
+ ]
373
+ )
374
+ enrichments = pd.concat(
375
+ [
376
+ gene_enrichment.assign(branch="Gene expression"),
377
+ dna_enrichment.assign(branch="DNA methylation"),
378
+ ],
379
+ ignore_index=True,
380
+ )
381
+ warning_text = (
382
+ "<br><small>" + " · ".join(html.escape(item) for item in warnings) + "</small>"
383
+ if warnings
384
+ else ""
385
+ )
386
+ summary = (
387
+ '<div class="status-box"><strong>Architecture ready.</strong> '
388
+ f"{len(gene_frame):,} paired samples · {len(label_names)} classes · "
389
+ f"{len(gene_branch.pathways) + len(dna_branch.pathways):,} "
390
+ f"branch-specific pathways · GenePT: {html.escape(embedding_file)}"
391
+ f"{warning_text}</div>"
392
+ )
393
+ progress(1.0, desc="Ready to train")
394
+ return workspace, summary, architecture, enrichments.head(100)
395
+ except Exception as exc:
396
+ return None, _status(str(exc), error=True), pd.DataFrame(), pd.DataFrame()
397
+
398
+
399
+ def _training_plots(history: list[dict[str, float]], confusion, labels):
400
+ history_frame = pd.DataFrame(history)
401
+ loss_figure = go.Figure()
402
+ loss_figure.add_trace(
403
+ go.Scatter(
404
+ x=history_frame["epoch"],
405
+ y=history_frame["training_loss"],
406
+ mode="lines",
407
+ name="Training",
408
+ line={"color": "#167c5a", "width": 3},
409
+ )
410
+ )
411
+ loss_figure.add_trace(
412
+ go.Scatter(
413
+ x=history_frame["epoch"],
414
+ y=history_frame["validation_loss"],
415
+ mode="lines",
416
+ name="Validation",
417
+ line={"color": "#e5a63c", "width": 3},
418
+ )
419
+ )
420
+ loss_figure.update_layout(
421
+ title="Loss by epoch",
422
+ xaxis_title="Epoch",
423
+ yaxis_title="Cross-entropy loss",
424
+ template="plotly_white",
425
+ margin={"l": 30, "r": 15, "t": 50, "b": 35},
426
+ legend={"orientation": "h", "y": 1.12},
427
+ )
428
+ confusion_figure = px.imshow(
429
+ confusion,
430
+ x=labels,
431
+ y=labels,
432
+ text_auto=True,
433
+ color_continuous_scale=[[0, "#eef6f2"], [1, "#167c5a"]],
434
+ labels={"x": "Predicted", "y": "Observed", "color": "Samples"},
435
+ title="Validation confusion matrix",
436
+ )
437
+ confusion_figure.update_layout(
438
+ template="plotly_white", margin={"l": 30, "r": 15, "t": 50, "b": 35}
439
+ )
440
+ return loss_figure, confusion_figure
441
+
442
+
443
+ def estimate_training_duration(
444
+ workspace: PreparedWorkspace | None,
445
+ epochs: int,
446
+ batch_size: int,
447
+ learning_rate: float,
448
+ weight_decay: float,
449
+ dropout: float,
450
+ projection_dim: int,
451
+ fusion_dim: int,
452
+ validation_fraction: float,
453
+ optimizer: str,
454
+ class_weighting: bool,
455
+ progress=None,
456
+ ) -> int:
457
+ """Estimate a conservative ZeroGPU reservation from the prepared graph.
458
+
459
+ ZeroGPU checks the declared duration against each visitor's remaining quota
460
+ before the call starts. Keep small jobs short for better queue priority and
461
+ cap a single free-tier training request at five minutes.
462
+ """
463
+
464
+ if workspace is None:
465
+ return 10
466
+ samples = max(int(len(workspace.labels)), 1)
467
+ biological_parameters = (
468
+ int(workspace.gene_branch.biological_mask.size)
469
+ + int(workspace.dna_branch.biological_mask.size)
470
+ )
471
+ sample_factor = max(samples / 875.0, 0.25)
472
+ graph_factor = max(math.sqrt(biological_parameters / 1_850_000.0), 0.3)
473
+ batch_factor = max((16.0 / max(int(batch_size), 1)) ** 0.35, 0.55)
474
+ seconds = 25 + int(epochs) * 0.8 * sample_factor * graph_factor * batch_factor
475
+ return int(min(300, max(30, math.ceil(seconds))))
476
+
477
+
478
+ @spaces.GPU(duration=estimate_training_duration)
479
+ def train_workspace(
480
+ workspace: PreparedWorkspace | None,
481
+ epochs: int,
482
+ batch_size: int,
483
+ learning_rate: float,
484
+ weight_decay: float,
485
+ dropout: float,
486
+ projection_dim: int,
487
+ fusion_dim: int,
488
+ validation_fraction: float,
489
+ optimizer: str,
490
+ class_weighting: bool,
491
+ progress=gr.Progress(track_tqdm=False),
492
+ ):
493
+ if workspace is None:
494
+ return (
495
+ None,
496
+ _status("Prepare data and priors in Phase 1 before training.", True),
497
+ None,
498
+ None,
499
+ pd.DataFrame(),
500
+ None,
501
+ pd.DataFrame(),
502
+ )
503
+ try:
504
+ parameters = Hyperparameters(
505
+ epochs=int(epochs),
506
+ batch_size=int(batch_size),
507
+ learning_rate=float(learning_rate),
508
+ weight_decay=float(weight_decay),
509
+ dropout=float(dropout),
510
+ projection_dim=int(projection_dim),
511
+ fusion_dim=int(fusion_dim),
512
+ validation_fraction=float(validation_fraction),
513
+ optimizer=optimizer,
514
+ class_weighting=bool(class_weighting),
515
+ )
516
+
517
+ def report(fraction: float, description: str) -> None:
518
+ progress(fraction, desc=description)
519
+
520
+ result = train(workspace, parameters, progress=report)
521
+ bundle = result.bundle
522
+ artifact = save_bundle(bundle)
523
+ metrics = bundle.metrics
524
+ metrics_html = f"""
525
+ <div class="status-box"><strong>Training complete.</strong>
526
+ Best validation checkpoint restored; the downloadable artifact includes
527
+ architecture, preprocessing, weights, and metrics.</div>
528
+ <div class="metric-grid" style="margin-top:10px">
529
+ <div class="metric"><span>Macro F1</span><b>{metrics['f1_macro']:.3f}</b></div>
530
+ <div class="metric"><span>Accuracy</span><b>{metrics['accuracy']:.3f}</b></div>
531
+ <div class="metric"><span>Macro precision</span><b>{metrics['precision_macro']:.3f}</b></div>
532
+ <div class="metric"><span>Macro recall</span><b>{metrics['recall_macro']:.3f}</b></div>
533
+ </div>
534
+ """
535
+ loss_plot, confusion_plot = _training_plots(
536
+ bundle.history, result.confusion, bundle.label_names
537
+ )
538
+ importance = pathway_importance(bundle).head(100)
539
+ return (
540
+ bundle,
541
+ metrics_html,
542
+ loss_plot,
543
+ confusion_plot,
544
+ result.validation_predictions,
545
+ artifact,
546
+ importance,
547
+ )
548
+ except Exception as exc:
549
+ return (
550
+ None,
551
+ _status(str(exc), error=True),
552
+ None,
553
+ None,
554
+ pd.DataFrame(),
555
+ None,
556
+ pd.DataFrame(),
557
+ )
558
+
559
+
560
+ def run_prediction(
561
+ bundle: ModelBundle | None,
562
+ uploaded_artifact: str | None,
563
+ gene_file: str | None,
564
+ dna_file: str | None,
565
+ ):
566
+ try:
567
+ active_bundle = (
568
+ load_bundle(uploaded_artifact) if uploaded_artifact else bundle
569
+ )
570
+ if active_bundle is None:
571
+ raise ValueError(
572
+ "Train a model in Phase 2 or upload a BioLM-NET model artifact."
573
+ )
574
+ gene_frame = _read_required_upload(
575
+ gene_file, "a prediction gene-expression CSV"
576
+ )
577
+ dna_frame = _read_required_upload(
578
+ dna_file, "a prediction DNA-methylation CSV"
579
+ )
580
+ output = predict(gene_frame, dna_frame, active_bundle)
581
+ destination = (
582
+ Path(tempfile.mkdtemp(prefix="biolmnet-prediction-"))
583
+ / "biolm-net-predictions.csv"
584
+ )
585
+ output.to_csv(destination, index=False)
586
+ counts = (
587
+ output["predicted_class"]
588
+ .value_counts()
589
+ .rename_axis("class")
590
+ .reset_index(name="samples")
591
+ )
592
+ figure = px.bar(
593
+ counts,
594
+ x="class",
595
+ y="samples",
596
+ color="class",
597
+ color_discrete_sequence=[
598
+ "#167c5a",
599
+ "#e5a63c",
600
+ "#497f93",
601
+ "#8d6fa8",
602
+ "#be6f55",
603
+ ],
604
+ title="Predicted class distribution",
605
+ )
606
+ figure.update_layout(
607
+ showlegend=False,
608
+ template="plotly_white",
609
+ margin={"l": 30, "r": 15, "t": 50, "b": 35},
610
+ )
611
+ status = _status(
612
+ f"Predicted {len(output):,} samples. Mean confidence: "
613
+ f"{output['confidence'].mean():.3f}."
614
+ )
615
+ return active_bundle, status, output, figure, str(destination)
616
+ except Exception as exc:
617
+ return bundle, _status(str(exc), True), pd.DataFrame(), None, None
618
+
619
+
620
+ THEME = gr.themes.Base(
621
+ primary_hue="emerald",
622
+ neutral_hue="slate",
623
+ )
624
+
625
+
626
+ with gr.Blocks(title="BioLM-NET Workbench") as demo:
627
+ workspace_state = gr.State(None)
628
+ model_state = gr.State(None)
629
+
630
+ gr.HTML(
631
+ """
632
+ <section class="biolm-hero">
633
+ <div class="eyebrow">Interpretable multi-omics modeling</div>
634
+ <h1>BioLM-NET<br>Workbench</h1>
635
+ <p>Build a biologically masked network from paired gene expression and
636
+ DNA methylation, train it with GenePT-guided pathway attention, then
637
+ carry the exact preprocessing and architecture into prediction.</p>
638
+ <div class="hero-meta">
639
+ <span class="hero-chip">PDI · DoRothEA</span>
640
+ <span class="hero-chip">PPI · STRING</span>
641
+ <span class="hero-chip">Pathways · KEGG</span>
642
+ <span class="hero-chip">Context · GenePT</span>
643
+ <span class="hero-chip">Compute · ZeroGPU on demand</span>
644
+ </div>
645
+ </section>
646
+ """
647
+ )
648
+
649
+ with gr.Tabs():
650
+ with gr.Tab("1 · Data & priors", id="data"):
651
+ gr.HTML(
652
+ """
653
+ <p class="phase-intro"><strong>Assemble the model graph.</strong>
654
+ Select an upstream example, point to a GitHub folder that follows
655
+ the BioLM-NET file convention, or upload paired omics files.</p>
656
+ """
657
+ )
658
+ with gr.Row():
659
+ with gr.Column(scale=7, elem_classes=["phase-card"]):
660
+ source_mode = gr.Radio(
661
+ [
662
+ "BioLM-NET examples",
663
+ "GitHub folder",
664
+ "Upload files",
665
+ ],
666
+ value="BioLM-NET examples",
667
+ label="Dataset source",
668
+ )
669
+ with gr.Column(visible=True) as example_group:
670
+ example_dataset = gr.Dropdown(
671
+ ["BRCA", "COAD", "GBM", "scTrioseq2"],
672
+ value="BRCA",
673
+ label="Repository dataset",
674
+ )
675
+ with gr.Column(visible=False) as github_group:
676
+ github_folder = gr.Textbox(
677
+ label="GitHub dataset folder",
678
+ placeholder=(
679
+ "https://github.com/owner/repo/tree/main/Dataset/BRCA"
680
+ ),
681
+ info=(
682
+ "The folder must contain the five standard "
683
+ "BioLM-NET CSV filenames."
684
+ ),
685
+ )
686
+ with gr.Column(visible=False) as upload_group:
687
+ with gr.Row():
688
+ uploaded_gene = gr.File(
689
+ label="Gene expression",
690
+ file_types=[".csv"],
691
+ type="filepath",
692
+ )
693
+ uploaded_dna = gr.File(
694
+ label="DNA methylation",
695
+ file_types=[".csv"],
696
+ type="filepath",
697
+ )
698
+ uploaded_labels = gr.File(
699
+ label="Labels",
700
+ file_types=[".csv"],
701
+ type="filepath",
702
+ )
703
+ with gr.Row():
704
+ uploaded_gene_pathway = gr.File(
705
+ label="Gene → pathway mapping",
706
+ file_types=[".csv"],
707
+ type="filepath",
708
+ )
709
+ uploaded_dna_pathway = gr.File(
710
+ label="DNA → pathway mapping",
711
+ file_types=[".csv"],
712
+ type="filepath",
713
+ )
714
+ pathway_files_are_significant = gr.Checkbox(
715
+ value=True,
716
+ label="Pathway files already contain significant pathways",
717
+ info=(
718
+ "Turn off for a full SYMBOL/PathwayID annotation "
719
+ "catalog; enrichment will use BH-adjusted p < 0.05."
720
+ ),
721
+ )
722
+ with gr.Column(scale=5, elem_classes=["phase-card"]):
723
+ embedding_option = gr.Dropdown(
724
+ list(GENEPT_OPTIONS),
725
+ value=list(GENEPT_OPTIONS)[0],
726
+ label="GenePT context",
727
+ )
728
+ with gr.Accordion("Interaction priors", open=False):
729
+ gr.Markdown(
730
+ "By default, the app retrieves `PDI.csv` and `PPI.csv` "
731
+ "from `bozdaglab/BioLM-NET`. Upload both only to override."
732
+ )
733
+ uploaded_pdi = gr.File(
734
+ label="Custom PDI.csv",
735
+ file_types=[".csv"],
736
+ type="filepath",
737
+ )
738
+ uploaded_ppi = gr.File(
739
+ label="Custom PPI.csv",
740
+ file_types=[".csv"],
741
+ type="filepath",
742
+ )
743
+ prepare_button = gr.Button(
744
+ "Build biological architecture",
745
+ variant="primary",
746
+ size="lg",
747
+ )
748
+
749
+ preparation_status = gr.HTML(
750
+ _status("Choose a source, then build the biological architecture.")
751
+ )
752
+ with gr.Row():
753
+ architecture_table = gr.Dataframe(
754
+ label="Sparse architecture audit",
755
+ interactive=False,
756
+ wrap=True,
757
+ )
758
+ enrichment_table = gr.Dataframe(
759
+ label="Retained enriched pathways (first 100)",
760
+ interactive=False,
761
+ wrap=True,
762
+ )
763
+ gr.HTML(
764
+ """
765
+ <p class="footnote">Expected orientation: samples in rows and
766
+ HGNC gene symbols in columns. PDI requires <code>TF, Target</code>;
767
+ PPI requires <code>protein1, protein2, combined_score</code>;
768
+ pathways require <code>SYMBOL, PathwayID</code>. PPI is filtered
769
+ to score &gt; 0.7 and the retained top decile, following the paper.</p>
770
+ """
771
+ )
772
+
773
+ with gr.Tab("2 · Train", id="train"):
774
+ gr.HTML(
775
+ """
776
+ <p class="phase-intro"><strong>Fit and evaluate.</strong> The
777
+ split is stratified; scaling is fit on training samples only;
778
+ the best validation checkpoint is exported as a safe,
779
+ self-contained model artifact. A shared GPU is requested only
780
+ while this training callback is running.</p>
781
+ """
782
+ )
783
+ with gr.Row():
784
+ with gr.Column(scale=4, elem_classes=["phase-card"]):
785
+ epochs = gr.Slider(5, 200, value=50, step=5, label="Epochs")
786
+ batch_size = gr.Dropdown(
787
+ [8, 16, 32, 64, 128], value=16, label="Batch size"
788
+ )
789
+ learning_rate = gr.Number(
790
+ value=0.001, label="Learning rate", minimum=0.000001
791
+ )
792
+ weight_decay = gr.Number(
793
+ value=0.01, label="L2 weight decay", minimum=0
794
+ )
795
+ dropout = gr.Slider(
796
+ 0, 0.8, value=0.3, step=0.05, label="Dropout"
797
+ )
798
+ with gr.Column(scale=4, elem_classes=["phase-card"]):
799
+ projection_dim = gr.Dropdown(
800
+ [16, 32, 64, 128], value=64, label="Branch projection"
801
+ )
802
+ fusion_dim = gr.Dropdown(
803
+ [8, 12, 16, 32], value=12, label="Fusion layer"
804
+ )
805
+ validation_fraction = gr.Slider(
806
+ 0.1,
807
+ 0.4,
808
+ value=0.2,
809
+ step=0.05,
810
+ label="Validation fraction",
811
+ )
812
+ optimizer = gr.Radio(
813
+ ["Adam", "SGD"], value="Adam", label="Optimizer"
814
+ )
815
+ class_weighting = gr.Checkbox(
816
+ value=True,
817
+ label="Balance classes in the loss",
818
+ info="Uses N / (classes × samples in class), as in the paper.",
819
+ )
820
+ with gr.Column(scale=4, elem_classes=["phase-card"]):
821
+ gr.Markdown(
822
+ """
823
+ **Paper-faithful defaults**
824
+
825
+ - First layer: trainable `W ⊙ M`
826
+ - PDI weights: binary
827
+ - PPI weights: normalized STRING score
828
+ - Pathway attention: GenePT query attention
829
+ - Fusion: dual branch → dense → softmax
830
+ """
831
+ )
832
+ train_button = gr.Button(
833
+ "Train BioLM-NET on ZeroGPU", variant="primary", size="lg"
834
+ )
835
+ model_download = gr.File(
836
+ label="Trained model artifact", interactive=False
837
+ )
838
+ gr.Markdown(
839
+ "ZeroGPU reserves 30–300 seconds according to dataset "
840
+ "size and epochs. Visitors use their own daily quota."
841
+ )
842
+ training_status = gr.HTML(
843
+ _status("Phase 2 unlocks after the architecture is prepared.")
844
+ )
845
+ with gr.Row():
846
+ loss_plot = gr.Plot(label="Training history")
847
+ confusion_plot = gr.Plot(label="Confusion matrix")
848
+ with gr.Row():
849
+ validation_table = gr.Dataframe(
850
+ label="Validation predictions",
851
+ interactive=False,
852
+ wrap=True,
853
+ )
854
+ importance_table = gr.Dataframe(
855
+ label="Pathway attention audit (first 100)",
856
+ interactive=False,
857
+ wrap=True,
858
+ )
859
+
860
+ with gr.Tab("3 · Predict", id="predict"):
861
+ gr.HTML(
862
+ """
863
+ <p class="phase-intro"><strong>Apply a trained model.</strong>
864
+ Continue with the model from this session or upload a previous
865
+ artifact. Feature names are validated and reordered exactly as
866
+ they were during training.</p>
867
+ """
868
+ )
869
+ with gr.Row():
870
+ with gr.Column(scale=4, elem_classes=["phase-card"]):
871
+ prediction_artifact = gr.File(
872
+ label="Optional trained model artifact",
873
+ file_types=[".zip"],
874
+ type="filepath",
875
+ )
876
+ prediction_gene = gr.File(
877
+ label="Prediction gene expression",
878
+ file_types=[".csv"],
879
+ type="filepath",
880
+ )
881
+ prediction_dna = gr.File(
882
+ label="Prediction DNA methylation",
883
+ file_types=[".csv"],
884
+ type="filepath",
885
+ )
886
+ predict_button = gr.Button(
887
+ "Make predictions", variant="primary", size="lg"
888
+ )
889
+ prediction_download = gr.File(
890
+ label="Prediction CSV", interactive=False
891
+ )
892
+ with gr.Column(scale=8):
893
+ prediction_status = gr.HTML(
894
+ _status("Use the current trained model or upload an artifact.")
895
+ )
896
+ prediction_plot = gr.Plot(label="Class distribution")
897
+ prediction_table = gr.Dataframe(
898
+ label="Predictions and class probabilities",
899
+ interactive=False,
900
+ wrap=True,
901
+ )
902
+
903
+ gr.Markdown(
904
+ """
905
+ <p class="footnote">Research use only. This interface reproduces the
906
+ architecture described by Rifat et al. and uses the upstream BioLM-NET
907
+ repository and GenePT embeddings. Validate cohorts, preprocessing, and
908
+ model performance before drawing biological or clinical conclusions.</p>
909
+ """
910
+ )
911
+
912
+ source_mode.change(
913
+ _source_visibility,
914
+ inputs=[source_mode],
915
+ outputs=[example_group, github_group, upload_group],
916
+ )
917
+ prepare_button.click(
918
+ prepare_workspace,
919
+ inputs=[
920
+ source_mode,
921
+ example_dataset,
922
+ github_folder,
923
+ uploaded_gene,
924
+ uploaded_dna,
925
+ uploaded_labels,
926
+ uploaded_gene_pathway,
927
+ uploaded_dna_pathway,
928
+ uploaded_pdi,
929
+ uploaded_ppi,
930
+ pathway_files_are_significant,
931
+ embedding_option,
932
+ ],
933
+ outputs=[
934
+ workspace_state,
935
+ preparation_status,
936
+ architecture_table,
937
+ enrichment_table,
938
+ ],
939
+ )
940
+ train_button.click(
941
+ train_workspace,
942
+ inputs=[
943
+ workspace_state,
944
+ epochs,
945
+ batch_size,
946
+ learning_rate,
947
+ weight_decay,
948
+ dropout,
949
+ projection_dim,
950
+ fusion_dim,
951
+ validation_fraction,
952
+ optimizer,
953
+ class_weighting,
954
+ ],
955
+ outputs=[
956
+ model_state,
957
+ training_status,
958
+ loss_plot,
959
+ confusion_plot,
960
+ validation_table,
961
+ model_download,
962
+ importance_table,
963
+ ],
964
+ )
965
+ predict_button.click(
966
+ run_prediction,
967
+ inputs=[
968
+ model_state,
969
+ prediction_artifact,
970
+ prediction_gene,
971
+ prediction_dna,
972
+ ],
973
+ outputs=[
974
+ model_state,
975
+ prediction_status,
976
+ prediction_table,
977
+ prediction_plot,
978
+ prediction_download,
979
+ ],
980
+ )
981
+
982
+
983
+ if __name__ == "__main__":
984
+ demo.queue(default_concurrency_limit=1).launch(theme=THEME, css=CSS)
biolmnet/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """BioLM-NET model and data utilities for the Hugging Face Space."""
2
+
3
+ from .model import BioLMNet
4
+
5
+ __all__ = ["BioLMNet"]
biolmnet/artifacts.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+ from safetensors.torch import load_file, save_file
12
+
13
+ from .model import BioLMNet
14
+ from .training import ModelBundle
15
+
16
+
17
+ EXPECTED_FILES = {"config.json", "arrays.npz", "model.safetensors", "metrics.json"}
18
+
19
+
20
+ def _branch_arrays(bundle: ModelBundle) -> dict[str, np.ndarray]:
21
+ model = bundle.model
22
+ return {
23
+ "gene_biological_mask": model.gene_branch.biological.mask.T.cpu().numpy(),
24
+ "dna_biological_mask": model.dna_branch.biological.mask.T.cpu().numpy(),
25
+ "gene_embeddings": (
26
+ model.gene_branch.pathway_attention.gene_embeddings.cpu().numpy()
27
+ ),
28
+ "dna_embeddings": (
29
+ model.dna_branch.pathway_attention.gene_embeddings.cpu().numpy()
30
+ ),
31
+ "gene_pathway_mask": (
32
+ model.gene_branch.pathway_attention.pathway_mask.cpu().numpy()
33
+ ),
34
+ "dna_pathway_mask": (
35
+ model.dna_branch.pathway_attention.pathway_mask.cpu().numpy()
36
+ ),
37
+ "gene_mean": bundle.gene_mean,
38
+ "gene_scale": bundle.gene_scale,
39
+ "dna_mean": bundle.dna_mean,
40
+ "dna_scale": bundle.dna_scale,
41
+ }
42
+
43
+
44
+ def save_bundle(bundle: ModelBundle, destination: str | Path | None = None) -> str:
45
+ if destination is None:
46
+ destination = Path(tempfile.mkdtemp(prefix="biolmnet-export-")) / (
47
+ "biolm-net-trained-model.zip"
48
+ )
49
+ destination = Path(destination)
50
+ destination.parent.mkdir(parents=True, exist_ok=True)
51
+ with tempfile.TemporaryDirectory(prefix="biolmnet-pack-") as directory:
52
+ root = Path(directory)
53
+ (root / "config.json").write_text(
54
+ json.dumps(bundle.config, indent=2), encoding="utf-8"
55
+ )
56
+ (root / "metrics.json").write_text(
57
+ json.dumps(
58
+ {"metrics": bundle.metrics, "history": bundle.history}, indent=2
59
+ ),
60
+ encoding="utf-8",
61
+ )
62
+ np.savez_compressed(root / "arrays.npz", **_branch_arrays(bundle))
63
+ state = {
64
+ key: value.detach().cpu().contiguous()
65
+ for key, value in bundle.model.state_dict().items()
66
+ }
67
+ save_file(state, root / "model.safetensors")
68
+ with zipfile.ZipFile(
69
+ destination, "w", compression=zipfile.ZIP_DEFLATED
70
+ ) as archive:
71
+ for filename in sorted(EXPECTED_FILES):
72
+ archive.write(root / filename, arcname=filename)
73
+ return str(destination)
74
+
75
+
76
+ def _safe_extract(archive_path: Path, directory: Path) -> None:
77
+ with zipfile.ZipFile(archive_path) as archive:
78
+ names = set(archive.namelist())
79
+ missing = EXPECTED_FILES - names
80
+ if missing:
81
+ raise ValueError(
82
+ "Model artifact is incomplete; missing " + ", ".join(sorted(missing))
83
+ )
84
+ for filename in EXPECTED_FILES:
85
+ info = archive.getinfo(filename)
86
+ if info.file_size > 1_000_000_000:
87
+ raise ValueError(f"Artifact member {filename} is unexpectedly large.")
88
+ with archive.open(info) as source, (directory / filename).open(
89
+ "wb"
90
+ ) as target:
91
+ shutil.copyfileobj(source, target)
92
+
93
+
94
+ def load_bundle(archive_path: str | Path) -> ModelBundle:
95
+ archive_path = Path(archive_path)
96
+ if archive_path.suffix.lower() != ".zip":
97
+ raise ValueError("Upload the .zip artifact produced by the training phase.")
98
+ with tempfile.TemporaryDirectory(prefix="biolmnet-load-") as directory_name:
99
+ directory = Path(directory_name)
100
+ _safe_extract(archive_path, directory)
101
+ config = json.loads((directory / "config.json").read_text("utf-8"))
102
+ if config.get("format_version") != 1:
103
+ raise ValueError("Unsupported BioLM-NET artifact version.")
104
+ metrics_payload = json.loads(
105
+ (directory / "metrics.json").read_text("utf-8")
106
+ )
107
+ with np.load(directory / "arrays.npz", allow_pickle=False) as data:
108
+ arrays = {key: data[key].copy() for key in data.files}
109
+
110
+ architecture = config["architecture"]
111
+ model = BioLMNet(
112
+ gene_biological_mask=torch.from_numpy(
113
+ arrays["gene_biological_mask"]
114
+ ),
115
+ dna_biological_mask=torch.from_numpy(
116
+ arrays["dna_biological_mask"]
117
+ ),
118
+ gene_embeddings=torch.from_numpy(arrays["gene_embeddings"]),
119
+ dna_embeddings=torch.from_numpy(arrays["dna_embeddings"]),
120
+ gene_pathway_mask=torch.from_numpy(arrays["gene_pathway_mask"]),
121
+ dna_pathway_mask=torch.from_numpy(arrays["dna_pathway_mask"]),
122
+ n_classes=len(config["label_names"]),
123
+ projection_dim=int(architecture["projection_dim"]),
124
+ fusion_dim=int(architecture["fusion_dim"]),
125
+ dropout=float(architecture["dropout"]),
126
+ biological_activation=architecture["biological_activation"],
127
+ projection_activation=architecture["projection_activation"],
128
+ fusion_activation=architecture["fusion_activation"],
129
+ )
130
+ state = load_file(directory / "model.safetensors", device="cpu")
131
+ model.load_state_dict(state, strict=True)
132
+ model.eval()
133
+ return ModelBundle(
134
+ model=model,
135
+ gene_features=list(config["gene_features"]),
136
+ dna_features=list(config["dna_features"]),
137
+ label_names=list(config["label_names"]),
138
+ gene_mean=arrays["gene_mean"],
139
+ gene_scale=arrays["gene_scale"],
140
+ dna_mean=arrays["dna_mean"],
141
+ dna_scale=arrays["dna_scale"],
142
+ config=config,
143
+ metrics=metrics_payload.get("metrics", {}),
144
+ history=metrics_payload.get("history", []),
145
+ )
biolmnet/data.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import re
6
+ import urllib.parse
7
+ import urllib.request
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import BinaryIO
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from scipy.stats import hypergeom
15
+
16
+
17
+ UPSTREAM_REPOSITORY = "https://github.com/bozdaglab/BioLM-NET"
18
+ UPSTREAM_RAW = "https://raw.githubusercontent.com/bozdaglab/BioLM-NET/main"
19
+ GENEPT_REPOSITORY = "honicky/genept-composable-embeddings"
20
+ DATASET_FILES = {
21
+ "gene": "Gene_Expression.csv",
22
+ "dna": "DNA_Methylation.csv",
23
+ "labels": "label.csv",
24
+ "gene_pathways": "ge_target_to_KEGG_significant.csv",
25
+ "dna_pathways": "dna_target_to_KEGG_significant.csv",
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class BranchPriors:
31
+ input_genes: list[str]
32
+ hidden_genes: list[str]
33
+ biological_mask: np.ndarray
34
+ pathways: list[str] = field(default_factory=list)
35
+ pathway_mask: np.ndarray | None = None
36
+ embeddings: np.ndarray | None = None
37
+ pdi_edges: int = 0
38
+ ppi_edges: int = 0
39
+ missing_embedding_genes: int = 0
40
+
41
+
42
+ @dataclass
43
+ class PreparedWorkspace:
44
+ gene_expression: np.ndarray
45
+ dna_methylation: np.ndarray
46
+ labels: np.ndarray
47
+ label_names: list[str]
48
+ gene_branch: BranchPriors
49
+ dna_branch: BranchPriors
50
+ source_name: str
51
+ warnings: list[str] = field(default_factory=list)
52
+
53
+
54
+ def _normalise_columns(frame: pd.DataFrame) -> pd.DataFrame:
55
+ result = frame.copy()
56
+ result.columns = [str(value).strip() for value in result.columns]
57
+ return result
58
+
59
+
60
+ def read_csv(source: str | Path | BinaryIO) -> pd.DataFrame:
61
+ if hasattr(source, "read"):
62
+ return _normalise_columns(pd.read_csv(source))
63
+ text = str(source)
64
+ if text.startswith(("http://", "https://")):
65
+ request = urllib.request.Request(
66
+ text, headers={"User-Agent": "BioLM-NET-HuggingFace-Space/1.0"}
67
+ )
68
+ with urllib.request.urlopen(request, timeout=60) as response:
69
+ payload = response.read()
70
+ return _normalise_columns(pd.read_csv(io.BytesIO(payload)))
71
+ return _normalise_columns(pd.read_csv(text))
72
+
73
+
74
+ def github_folder_to_raw_base(folder_url: str) -> str:
75
+ value = folder_url.strip().rstrip("/")
76
+ if value.startswith("https://raw.githubusercontent.com/"):
77
+ return value
78
+ match = re.match(
79
+ r"https://github\.com/([^/]+)/([^/]+)/(?:tree|blob)/([^/]+)(?:/(.*))?$",
80
+ value,
81
+ )
82
+ if match:
83
+ owner, repository, branch, folder = match.groups()
84
+ suffix = f"/{folder}" if folder else ""
85
+ return (
86
+ f"https://raw.githubusercontent.com/{owner}/{repository}/"
87
+ f"{branch}{suffix}"
88
+ )
89
+ match = re.match(r"https://github\.com/([^/]+)/([^/]+)$", value)
90
+ if match:
91
+ owner, repository = match.groups()
92
+ return f"https://raw.githubusercontent.com/{owner}/{repository}/main"
93
+ raise ValueError(
94
+ "Use a GitHub repository/folder URL such as "
95
+ "https://github.com/owner/repo/tree/main/Dataset/BRCA."
96
+ )
97
+
98
+
99
+ def upstream_example_sources(dataset: str) -> dict[str, str]:
100
+ if dataset not in {"BRCA", "COAD", "GBM", "scTrioseq2"}:
101
+ raise ValueError(f"Unknown BioLM-NET example dataset: {dataset}")
102
+ base = f"{UPSTREAM_RAW}/Dataset/{dataset}"
103
+ return {key: f"{base}/{filename}" for key, filename in DATASET_FILES.items()}
104
+
105
+
106
+ def github_dataset_sources(folder_url: str) -> dict[str, str]:
107
+ base = github_folder_to_raw_base(folder_url)
108
+ return {key: f"{base}/{filename}" for key, filename in DATASET_FILES.items()}
109
+
110
+
111
+ def upstream_interaction_sources() -> tuple[str, str]:
112
+ return (
113
+ f"{UPSTREAM_RAW}/Dataset/PDI/PDI.csv",
114
+ f"{UPSTREAM_RAW}/Dataset/PPI/PPI.csv",
115
+ )
116
+
117
+
118
+ def validate_and_align_omics(
119
+ gene_frame: pd.DataFrame,
120
+ dna_frame: pd.DataFrame,
121
+ labels_frame: pd.DataFrame,
122
+ *,
123
+ allow_preset_trim: bool = False,
124
+ ) -> tuple[pd.DataFrame, pd.DataFrame, np.ndarray, list[str], list[str]]:
125
+ warnings: list[str] = []
126
+ if gene_frame.columns.duplicated().any() or dna_frame.columns.duplicated().any():
127
+ raise ValueError("Omics files must have unique gene-name columns.")
128
+ if labels_frame.shape[1] != 1:
129
+ raise ValueError("The label file must contain exactly one column.")
130
+ if gene_frame.empty or dna_frame.empty or labels_frame.empty:
131
+ raise ValueError("Gene expression, DNA methylation, and labels cannot be empty.")
132
+
133
+ counts = [len(gene_frame), len(dna_frame), len(labels_frame)]
134
+ if len(set(counts)) != 1:
135
+ if not allow_preset_trim:
136
+ raise ValueError(
137
+ "The two omics files and label file must contain the same number "
138
+ f"of rows; received {counts}."
139
+ )
140
+ common = min(counts)
141
+ warnings.append(
142
+ f"The upstream example has row counts {counts}; all inputs were "
143
+ f"aligned to the first {common} rows, matching repository order."
144
+ )
145
+ gene_frame = gene_frame.iloc[:common].reset_index(drop=True)
146
+ dna_frame = dna_frame.iloc[:common].reset_index(drop=True)
147
+ labels_frame = labels_frame.iloc[:common].reset_index(drop=True)
148
+
149
+ for name, frame in (
150
+ ("gene expression", gene_frame),
151
+ ("DNA methylation", dna_frame),
152
+ ):
153
+ converted = frame.apply(pd.to_numeric, errors="coerce")
154
+ invalid = int(converted.isna().sum().sum())
155
+ if invalid:
156
+ raise ValueError(
157
+ f"{name.title()} contains {invalid:,} missing or non-numeric values."
158
+ )
159
+ if not np.isfinite(converted.to_numpy(dtype=np.float64)).all():
160
+ raise ValueError(f"{name.title()} contains infinite values.")
161
+ if name == "gene expression":
162
+ gene_frame = converted
163
+ else:
164
+ dna_frame = converted
165
+
166
+ raw_labels = labels_frame.iloc[:, 0]
167
+ if raw_labels.isna().any():
168
+ raise ValueError("Labels cannot be empty.")
169
+ labels = raw_labels.astype(str).str.strip()
170
+ if labels.eq("").any():
171
+ raise ValueError("Labels cannot be empty.")
172
+ unique_labels = sorted(labels.unique().tolist())
173
+ if len(unique_labels) < 2:
174
+ raise ValueError("Training requires at least two label classes.")
175
+ label_to_index = {label: index for index, label in enumerate(unique_labels)}
176
+ encoded = labels.map(label_to_index).to_numpy(dtype=np.int64)
177
+ return gene_frame, dna_frame, encoded, unique_labels, warnings
178
+
179
+
180
+ def _clean_interactions(
181
+ pdi_frame: pd.DataFrame, ppi_frame: pd.DataFrame
182
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
183
+ required_pdi = {"TF", "Target"}
184
+ required_ppi = {"protein1", "protein2", "combined_score"}
185
+ if not required_pdi.issubset(pdi_frame.columns):
186
+ raise ValueError("PDI.csv must contain TF and Target columns.")
187
+ if not required_ppi.issubset(ppi_frame.columns):
188
+ raise ValueError(
189
+ "PPI.csv must contain protein1, protein2, and combined_score columns."
190
+ )
191
+
192
+ pdi = pdi_frame.loc[:, ["TF", "Target"]].dropna().copy()
193
+ pdi["TF"] = pdi["TF"].astype(str).str.strip()
194
+ pdi["Target"] = pdi["Target"].astype(str).str.strip()
195
+ pdi = pdi[(pdi["TF"] != "") & (pdi["Target"] != "")].drop_duplicates()
196
+
197
+ ppi = ppi_frame.loc[
198
+ :, ["protein1", "protein2", "combined_score"]
199
+ ].dropna().copy()
200
+ ppi["protein1"] = ppi["protein1"].astype(str).str.strip()
201
+ ppi["protein2"] = ppi["protein2"].astype(str).str.strip()
202
+ ppi["combined_score"] = pd.to_numeric(
203
+ ppi["combined_score"], errors="coerce"
204
+ )
205
+ ppi = ppi.dropna()
206
+ if ppi["combined_score"].max() > 1:
207
+ ppi["combined_score"] = ppi["combined_score"] / 1000.0
208
+ ppi = ppi[ppi["combined_score"] > 0.7]
209
+ if ppi.empty:
210
+ raise ValueError("No PPI interactions remain above score 0.7.")
211
+ threshold = float(ppi["combined_score"].quantile(0.9))
212
+ ppi = ppi[ppi["combined_score"] >= threshold].drop_duplicates(
213
+ ["protein1", "protein2"]
214
+ )
215
+ return pdi, ppi
216
+
217
+
218
+ def build_biological_mask(
219
+ input_genes: list[str],
220
+ pdi_frame: pd.DataFrame,
221
+ ppi_frame: pd.DataFrame,
222
+ ) -> BranchPriors:
223
+ pdi, ppi = _clean_interactions(pdi_frame, ppi_frame)
224
+ input_genes = [str(gene).strip() for gene in input_genes]
225
+ input_index = {gene: index for index, gene in enumerate(input_genes)}
226
+ input_set = set(input_genes)
227
+
228
+ # The paper retains PDI targets that are DE/HVG; both TF and target must
229
+ # therefore be represented in the input branch.
230
+ pdi_selected = pdi[
231
+ pdi["TF"].isin(input_set) & pdi["Target"].isin(input_set)
232
+ ].copy()
233
+
234
+ # STRING PPI is undirected. Add the partner of every input protein,
235
+ # regardless of which endpoint it occupies in the source file.
236
+ forward = ppi[ppi["protein1"].isin(input_set)].rename(
237
+ columns={"protein1": "source", "protein2": "target"}
238
+ )
239
+ reverse = ppi[ppi["protein2"].isin(input_set)].rename(
240
+ columns={"protein2": "source", "protein1": "target"}
241
+ )
242
+ ppi_selected = pd.concat(
243
+ [
244
+ forward[["source", "target", "combined_score"]],
245
+ reverse[["source", "target", "combined_score"]],
246
+ ],
247
+ ignore_index=True,
248
+ ).drop_duplicates(["source", "target"])
249
+
250
+ hidden_genes = sorted(
251
+ set(pdi_selected["Target"].tolist())
252
+ | set(ppi_selected["target"].tolist())
253
+ )
254
+ if not hidden_genes:
255
+ raise ValueError(
256
+ "None of the input genes have retained PDI/PPI connections. "
257
+ "Check that columns use HGNC gene symbols."
258
+ )
259
+ hidden_index = {gene: index for index, gene in enumerate(hidden_genes)}
260
+ mask = np.zeros((len(input_genes), len(hidden_genes)), dtype=np.float32)
261
+
262
+ for row in pdi_selected.itertuples(index=False):
263
+ mask[input_index[row.TF], hidden_index[row.Target]] = 1.0
264
+ for row in ppi_selected.itertuples(index=False):
265
+ source_index = input_index[row.source]
266
+ target_index = hidden_index[row.target]
267
+ mask[source_index, target_index] = max(
268
+ mask[source_index, target_index], float(row.combined_score)
269
+ )
270
+
271
+ active = mask.sum(axis=0) > 0
272
+ return BranchPriors(
273
+ input_genes=input_genes,
274
+ hidden_genes=[
275
+ gene for gene, keep in zip(hidden_genes, active, strict=True) if keep
276
+ ],
277
+ biological_mask=mask[:, active],
278
+ pdi_edges=int(len(pdi_selected)),
279
+ ppi_edges=int(len(ppi_selected)),
280
+ )
281
+
282
+
283
+ def _benjamini_hochberg(p_values: np.ndarray) -> np.ndarray:
284
+ count = len(p_values)
285
+ order = np.argsort(p_values)
286
+ ranked = p_values[order]
287
+ adjusted = ranked * count / np.arange(1, count + 1)
288
+ adjusted = np.minimum.accumulate(adjusted[::-1])[::-1]
289
+ output = np.empty_like(adjusted)
290
+ output[order] = np.clip(adjusted, 0.0, 1.0)
291
+ return output
292
+
293
+
294
+ def build_pathway_mask(
295
+ hidden_genes: list[str],
296
+ pathway_frame: pd.DataFrame,
297
+ *,
298
+ precomputed_significant: bool,
299
+ adjusted_p_threshold: float = 0.05,
300
+ ) -> tuple[list[str], np.ndarray, pd.DataFrame]:
301
+ required = {"SYMBOL", "PathwayID"}
302
+ if not required.issubset(pathway_frame.columns):
303
+ raise ValueError("Pathway data must contain SYMBOL and PathwayID columns.")
304
+ mapping = pathway_frame.loc[:, ["SYMBOL", "PathwayID"]].dropna().copy()
305
+ mapping["SYMBOL"] = mapping["SYMBOL"].astype(str).str.strip()
306
+ mapping["PathwayID"] = mapping["PathwayID"].astype(str).str.strip()
307
+ mapping = mapping[
308
+ (mapping["SYMBOL"] != "") & (mapping["PathwayID"] != "")
309
+ ].drop_duplicates()
310
+
311
+ hidden_set = set(hidden_genes)
312
+ overlap = mapping[mapping["SYMBOL"].isin(hidden_set)]
313
+ if overlap.empty:
314
+ raise ValueError(
315
+ "No PDI/PPI hidden genes overlap the supplied pathway annotations."
316
+ )
317
+
318
+ rows: list[dict[str, float | int | str]] = []
319
+ if precomputed_significant:
320
+ for pathway, group in overlap.groupby("PathwayID"):
321
+ rows.append(
322
+ {
323
+ "PathwayID": pathway,
324
+ "overlap_genes": int(group["SYMBOL"].nunique()),
325
+ "adjusted_p_value": np.nan,
326
+ }
327
+ )
328
+ else:
329
+ universe = set(mapping["SYMBOL"])
330
+ selected = hidden_set & universe
331
+ population = len(universe)
332
+ draws = len(selected)
333
+ for pathway, group in mapping.groupby("PathwayID"):
334
+ members = set(group["SYMBOL"])
335
+ successes = len(members)
336
+ observed = len(selected & members)
337
+ if observed == 0:
338
+ continue
339
+ p_value = float(
340
+ hypergeom.sf(observed - 1, population, successes, draws)
341
+ )
342
+ rows.append(
343
+ {
344
+ "PathwayID": pathway,
345
+ "overlap_genes": observed,
346
+ "p_value": p_value,
347
+ }
348
+ )
349
+ if rows:
350
+ p_values = np.array([float(row["p_value"]) for row in rows])
351
+ adjusted = _benjamini_hochberg(p_values)
352
+ for row, value in zip(rows, adjusted, strict=True):
353
+ row["adjusted_p_value"] = float(value)
354
+ rows = [
355
+ row
356
+ for row in rows
357
+ if float(row["adjusted_p_value"]) < adjusted_p_threshold
358
+ ]
359
+
360
+ enrichment = pd.DataFrame(rows)
361
+ if enrichment.empty:
362
+ raise ValueError(
363
+ "No significantly enriched pathways remain at BH-adjusted p < 0.05. "
364
+ "Upload a broader gene-to-pathway annotation catalog or revise the "
365
+ "input feature selection."
366
+ )
367
+ enrichment = enrichment.sort_values(
368
+ ["overlap_genes", "PathwayID"], ascending=[False, True]
369
+ ).reset_index(drop=True)
370
+ pathways = enrichment["PathwayID"].astype(str).tolist()
371
+ gene_index = {gene: index for index, gene in enumerate(hidden_genes)}
372
+ pathway_index = {
373
+ pathway: index for index, pathway in enumerate(pathways)
374
+ }
375
+ mask = np.zeros((len(hidden_genes), len(pathways)), dtype=bool)
376
+ kept_mapping = overlap[overlap["PathwayID"].isin(pathway_index)]
377
+ for row in kept_mapping.itertuples(index=False):
378
+ mask[gene_index[row.SYMBOL], pathway_index[row.PathwayID]] = True
379
+
380
+ active_pathways = mask.sum(axis=0) > 0
381
+ pathways = [
382
+ pathway
383
+ for pathway, keep in zip(pathways, active_pathways, strict=True)
384
+ if keep
385
+ ]
386
+ return pathways, mask[:, active_pathways], enrichment
387
+
388
+
389
+ def deterministic_gene_embeddings(
390
+ genes: list[str], dimensions: int = 64
391
+ ) -> pd.DataFrame:
392
+ """Deterministic test/fallback embeddings, never silently used for GenePT."""
393
+
394
+ vectors = []
395
+ for gene in genes:
396
+ digest = hashlib.sha256(gene.encode("utf-8")).digest()
397
+ seed = int.from_bytes(digest[:8], "little")
398
+ generator = np.random.default_rng(seed)
399
+ vector = generator.normal(0, 1, dimensions).astype(np.float32)
400
+ vector /= max(float(np.linalg.norm(vector)), 1e-8)
401
+ vectors.append(vector)
402
+ return pd.DataFrame(vectors, index=genes)
403
+
404
+
405
+ def load_genept_embeddings(filename: str) -> pd.DataFrame:
406
+ try:
407
+ from huggingface_hub import hf_hub_download
408
+ except ImportError as exc:
409
+ raise RuntimeError(
410
+ "huggingface_hub is required to retrieve GenePT embeddings."
411
+ ) from exc
412
+ path = hf_hub_download(
413
+ repo_id=GENEPT_REPOSITORY,
414
+ filename=filename,
415
+ repo_type="model",
416
+ )
417
+ frame = pd.read_parquet(path)
418
+ frame.index = frame.index.astype(str).str.strip()
419
+ return frame
420
+
421
+
422
+ def attach_embeddings_and_pathways(
423
+ branch: BranchPriors,
424
+ embedding_frame: pd.DataFrame,
425
+ pathway_frame: pd.DataFrame,
426
+ *,
427
+ precomputed_significant: bool,
428
+ ) -> pd.DataFrame:
429
+ embedding_index = set(embedding_frame.index.astype(str))
430
+ keep = np.array(
431
+ [gene in embedding_index for gene in branch.hidden_genes], dtype=bool
432
+ )
433
+ branch.missing_embedding_genes = int((~keep).sum())
434
+ if not keep.any():
435
+ raise ValueError(
436
+ "No retained PDI/PPI genes have embeddings in the selected GenePT file."
437
+ )
438
+ branch.hidden_genes = [
439
+ gene
440
+ for gene, retained in zip(branch.hidden_genes, keep, strict=True)
441
+ if retained
442
+ ]
443
+ branch.biological_mask = branch.biological_mask[:, keep]
444
+ branch.embeddings = (
445
+ embedding_frame.loc[branch.hidden_genes].to_numpy(dtype=np.float32)
446
+ )
447
+ (
448
+ branch.pathways,
449
+ branch.pathway_mask,
450
+ enrichment,
451
+ ) = build_pathway_mask(
452
+ branch.hidden_genes,
453
+ pathway_frame,
454
+ precomputed_significant=precomputed_significant,
455
+ )
456
+
457
+ genes_with_pathways = branch.pathway_mask.sum(axis=1) > 0
458
+ if not genes_with_pathways.any():
459
+ raise ValueError("No embedded hidden genes belong to a retained pathway.")
460
+ branch.hidden_genes = [
461
+ gene
462
+ for gene, retained in zip(
463
+ branch.hidden_genes, genes_with_pathways, strict=True
464
+ )
465
+ if retained
466
+ ]
467
+ branch.biological_mask = branch.biological_mask[:, genes_with_pathways]
468
+ branch.embeddings = branch.embeddings[genes_with_pathways]
469
+ branch.pathway_mask = branch.pathway_mask[genes_with_pathways]
470
+ return enrichment
biolmnet/model.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ import torch
7
+ from torch import Tensor, nn
8
+ from torch.nn import functional as F
9
+
10
+
11
+ def _activation(name: str) -> nn.Module:
12
+ choices: dict[str, nn.Module] = {
13
+ "relu": nn.ReLU(),
14
+ "gelu": nn.GELU(),
15
+ "sigmoid": nn.Sigmoid(),
16
+ "tanh": nn.Tanh(),
17
+ }
18
+ try:
19
+ return choices[name.lower()]
20
+ except KeyError as exc:
21
+ raise ValueError(f"Unsupported activation: {name}") from exc
22
+
23
+
24
+ class BioMaskedLinear(nn.Module):
25
+ """Trainable linear layer whose weights are constrained by a biological mask.
26
+
27
+ The paper defines W_masked = W ⊙ M. The public notebook approximates this
28
+ with a dense projection followed by a fixed matrix multiplication; this
29
+ layer implements the paper's equation directly.
30
+ """
31
+
32
+ def __init__(self, mask: Tensor, bias: bool = True) -> None:
33
+ super().__init__()
34
+ if mask.ndim != 2:
35
+ raise ValueError("Biological mask must be [input_genes, hidden_genes].")
36
+ if mask.shape[0] == 0 or mask.shape[1] == 0:
37
+ raise ValueError("Biological mask cannot be empty.")
38
+
39
+ input_features, output_features = mask.shape
40
+ self.input_features = int(input_features)
41
+ self.output_features = int(output_features)
42
+ self.weight = nn.Parameter(torch.empty(output_features, input_features))
43
+ self.bias = nn.Parameter(torch.empty(output_features)) if bias else None
44
+ self.register_buffer("mask", mask.T.to(dtype=torch.float32).contiguous())
45
+ self.reset_parameters()
46
+
47
+ def reset_parameters(self) -> None:
48
+ nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
49
+ if self.bias is not None:
50
+ fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight)
51
+ bound = 1 / math.sqrt(fan_in) if fan_in > 0 else 0
52
+ nn.init.uniform_(self.bias, -bound, bound)
53
+
54
+ def forward(self, inputs: Tensor) -> Tensor:
55
+ return F.linear(inputs, self.weight * self.mask, self.bias)
56
+
57
+
58
+ class AttentionPathwayLayer(nn.Module):
59
+ """GenePT-guided attention from biological hidden genes to pathways."""
60
+
61
+ def __init__(self, gene_embeddings: Tensor, pathway_mask: Tensor) -> None:
62
+ super().__init__()
63
+ if gene_embeddings.ndim != 2:
64
+ raise ValueError("Gene embeddings must be [hidden_genes, embedding_dim].")
65
+ if pathway_mask.ndim != 2:
66
+ raise ValueError("Pathway mask must be [hidden_genes, pathways].")
67
+ if gene_embeddings.shape[0] != pathway_mask.shape[0]:
68
+ raise ValueError("Gene embeddings and pathway mask must share gene order.")
69
+ if torch.any(pathway_mask.sum(dim=0) == 0):
70
+ raise ValueError("Every pathway must contain at least one retained gene.")
71
+
72
+ embeddings = gene_embeddings.to(dtype=torch.float32)
73
+ membership = pathway_mask.to(dtype=torch.bool)
74
+ self.register_buffer("gene_embeddings", embeddings)
75
+ self.register_buffer("pathway_mask", membership)
76
+ self.query = nn.Parameter(
77
+ torch.empty(pathway_mask.shape[1], gene_embeddings.shape[1])
78
+ )
79
+ nn.init.xavier_uniform_(self.query)
80
+
81
+ def attention_weights(self) -> Tensor:
82
+ scale = math.sqrt(self.gene_embeddings.shape[1])
83
+ # [hidden genes, pathways]
84
+ scores = self.gene_embeddings @ self.query.T / scale
85
+ scores = scores.masked_fill(~self.pathway_mask, torch.finfo(scores.dtype).min)
86
+ return torch.softmax(scores, dim=0)
87
+
88
+ def forward(self, hidden_gene_signal: Tensor) -> Tensor:
89
+ return hidden_gene_signal @ self.attention_weights()
90
+
91
+
92
+ class BioBranch(nn.Module):
93
+ def __init__(
94
+ self,
95
+ biological_mask: Tensor,
96
+ gene_embeddings: Tensor,
97
+ pathway_mask: Tensor,
98
+ projection_dim: int,
99
+ dropout: float,
100
+ biological_activation: str,
101
+ projection_activation: str,
102
+ ) -> None:
103
+ super().__init__()
104
+ self.biological = BioMaskedLinear(biological_mask)
105
+ self.biological_activation = _activation(biological_activation)
106
+ self.dropout = nn.Dropout(dropout)
107
+ self.pathway_attention = AttentionPathwayLayer(
108
+ gene_embeddings, pathway_mask
109
+ )
110
+ self.projection = nn.Linear(pathway_mask.shape[1], projection_dim)
111
+ self.projection_activation = _activation(projection_activation)
112
+
113
+ def forward(self, inputs: Tensor) -> Tensor:
114
+ hidden = self.dropout(self.biological_activation(self.biological(inputs)))
115
+ pathways = self.pathway_attention(hidden)
116
+ return self.projection_activation(self.projection(pathways))
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ModelDimensions:
121
+ gene_inputs: int
122
+ dna_inputs: int
123
+ gene_hidden: int
124
+ dna_hidden: int
125
+ gene_pathways: int
126
+ dna_pathways: int
127
+ classes: int
128
+
129
+
130
+ class BioLMNet(nn.Module):
131
+ """Dual-omics BioLM-NET classifier."""
132
+
133
+ def __init__(
134
+ self,
135
+ gene_biological_mask: Tensor,
136
+ dna_biological_mask: Tensor,
137
+ gene_embeddings: Tensor,
138
+ dna_embeddings: Tensor,
139
+ gene_pathway_mask: Tensor,
140
+ dna_pathway_mask: Tensor,
141
+ n_classes: int,
142
+ projection_dim: int = 64,
143
+ fusion_dim: int = 12,
144
+ dropout: float = 0.3,
145
+ biological_activation: str = "relu",
146
+ projection_activation: str = "sigmoid",
147
+ fusion_activation: str = "tanh",
148
+ ) -> None:
149
+ super().__init__()
150
+ if n_classes < 2:
151
+ raise ValueError("BioLM-NET requires at least two label classes.")
152
+
153
+ self.gene_branch = BioBranch(
154
+ gene_biological_mask,
155
+ gene_embeddings,
156
+ gene_pathway_mask,
157
+ projection_dim,
158
+ dropout,
159
+ biological_activation,
160
+ projection_activation,
161
+ )
162
+ self.dna_branch = BioBranch(
163
+ dna_biological_mask,
164
+ dna_embeddings,
165
+ dna_pathway_mask,
166
+ projection_dim,
167
+ dropout,
168
+ biological_activation,
169
+ projection_activation,
170
+ )
171
+ self.fusion = nn.Linear(projection_dim * 2, fusion_dim)
172
+ self.fusion_activation = _activation(fusion_activation)
173
+ self.fusion_dropout = nn.Dropout(dropout)
174
+ self.output = nn.Linear(fusion_dim, n_classes)
175
+ self.dimensions = ModelDimensions(
176
+ gene_inputs=gene_biological_mask.shape[0],
177
+ dna_inputs=dna_biological_mask.shape[0],
178
+ gene_hidden=gene_biological_mask.shape[1],
179
+ dna_hidden=dna_biological_mask.shape[1],
180
+ gene_pathways=gene_pathway_mask.shape[1],
181
+ dna_pathways=dna_pathway_mask.shape[1],
182
+ classes=n_classes,
183
+ )
184
+
185
+ def forward(self, gene_expression: Tensor, dna_methylation: Tensor) -> Tensor:
186
+ gene_projection = self.gene_branch(gene_expression)
187
+ dna_projection = self.dna_branch(dna_methylation)
188
+ fused = torch.cat([gene_projection, dna_projection], dim=1)
189
+ fused = self.fusion_dropout(
190
+ self.fusion_activation(self.fusion(fused))
191
+ )
192
+ return self.output(fused)
193
+
194
+ def pathway_attention(self) -> dict[str, Tensor]:
195
+ return {
196
+ "gene_expression": self.gene_branch.pathway_attention.attention_weights(),
197
+ "dna_methylation": self.dna_branch.pathway_attention.attention_weights(),
198
+ }
biolmnet/training.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import random
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Callable
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import torch
11
+ from sklearn.metrics import (
12
+ accuracy_score,
13
+ confusion_matrix,
14
+ f1_score,
15
+ precision_score,
16
+ recall_score,
17
+ )
18
+ from sklearn.model_selection import train_test_split
19
+ from torch import nn
20
+ from torch.utils.data import DataLoader, TensorDataset
21
+
22
+ from .data import PreparedWorkspace
23
+ from .model import BioLMNet
24
+
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:
46
+ raise ValueError("Epochs must be between 1 and 1,000.")
47
+ if not 2 <= self.batch_size <= 1024:
48
+ raise ValueError("Batch size must be between 2 and 1,024.")
49
+ if not 0 < self.learning_rate <= 1:
50
+ raise ValueError("Learning rate must be in (0, 1].")
51
+ if not 0 <= self.dropout < 1:
52
+ raise ValueError("Dropout must be in [0, 1).")
53
+ if not 0.05 <= self.validation_fraction <= 0.5:
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
60
+ class ModelBundle:
61
+ model: BioLMNet
62
+ gene_features: list[str]
63
+ dna_features: list[str]
64
+ label_names: list[str]
65
+ gene_mean: np.ndarray
66
+ gene_scale: np.ndarray
67
+ dna_mean: np.ndarray
68
+ dna_scale: np.ndarray
69
+ config: dict
70
+ metrics: dict
71
+ history: list[dict[str, float]]
72
+
73
+
74
+ @dataclass
75
+ class TrainingResult:
76
+ bundle: ModelBundle
77
+ validation_predictions: pd.DataFrame
78
+ confusion: np.ndarray
79
+
80
+
81
+ def set_reproducible_seed(seed: int) -> None:
82
+ random.seed(seed)
83
+ np.random.seed(seed)
84
+ torch.manual_seed(seed)
85
+ if torch.cuda.is_available():
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)
92
+ scale[scale < 1e-8] = 1.0
93
+ return mean, scale
94
+
95
+
96
+ def _scale(values: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
97
+ return ((values.astype(np.float32) - mean) / scale).astype(np.float32)
98
+
99
+
100
+ def _make_model(
101
+ workspace: PreparedWorkspace, hyperparameters: Hyperparameters
102
+ ) -> BioLMNet:
103
+ gene = workspace.gene_branch
104
+ dna = workspace.dna_branch
105
+ if (
106
+ gene.embeddings is None
107
+ or dna.embeddings is None
108
+ or gene.pathway_mask is None
109
+ or dna.pathway_mask is None
110
+ ):
111
+ raise ValueError("Workspace priors are incomplete; run data preparation first.")
112
+
113
+ return BioLMNet(
114
+ gene_biological_mask=torch.from_numpy(gene.biological_mask),
115
+ dna_biological_mask=torch.from_numpy(dna.biological_mask),
116
+ gene_embeddings=torch.from_numpy(gene.embeddings),
117
+ dna_embeddings=torch.from_numpy(dna.embeddings),
118
+ gene_pathway_mask=torch.from_numpy(gene.pathway_mask),
119
+ dna_pathway_mask=torch.from_numpy(dna.pathway_mask),
120
+ n_classes=len(workspace.label_names),
121
+ projection_dim=hyperparameters.projection_dim,
122
+ fusion_dim=hyperparameters.fusion_dim,
123
+ dropout=hyperparameters.dropout,
124
+ )
125
+
126
+
127
+ def _evaluate(
128
+ model: BioLMNet,
129
+ gene_values: np.ndarray,
130
+ dna_values: np.ndarray,
131
+ labels: np.ndarray,
132
+ device: torch.device,
133
+ ) -> tuple[float, np.ndarray, np.ndarray]:
134
+ model.eval()
135
+ with torch.no_grad():
136
+ logits = model(
137
+ torch.from_numpy(gene_values).to(device),
138
+ torch.from_numpy(dna_values).to(device),
139
+ )
140
+ loss = nn.functional.cross_entropy(
141
+ logits, torch.from_numpy(labels).to(device)
142
+ ).item()
143
+ probabilities = torch.softmax(logits, dim=1).cpu().numpy()
144
+ predictions = probabilities.argmax(axis=1)
145
+ return float(loss), probabilities, predictions
146
+
147
+
148
+ def train(
149
+ workspace: PreparedWorkspace,
150
+ hyperparameters: Hyperparameters,
151
+ progress: ProgressCallback | None = None,
152
+ ) -> TrainingResult:
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]
171
+ )
172
+ dna_mean, dna_scale = _fit_scaler(workspace.dna_methylation[train_index])
173
+ gene_train = _scale(
174
+ workspace.gene_expression[train_index], gene_mean, gene_scale
175
+ )
176
+ gene_validation = _scale(
177
+ workspace.gene_expression[validation_index], gene_mean, gene_scale
178
+ )
179
+ dna_train = _scale(
180
+ workspace.dna_methylation[train_index], dna_mean, dna_scale
181
+ )
182
+ dna_validation = _scale(
183
+ workspace.dna_methylation[validation_index], dna_mean, dna_scale
184
+ )
185
+ y_train = labels[train_index]
186
+ y_validation = labels[validation_index]
187
+
188
+ dataset = TensorDataset(
189
+ torch.from_numpy(gene_train),
190
+ torch.from_numpy(dna_train),
191
+ torch.from_numpy(y_train),
192
+ )
193
+ generator = torch.Generator().manual_seed(hyperparameters.seed)
194
+ loader = DataLoader(
195
+ dataset,
196
+ batch_size=min(hyperparameters.batch_size, len(dataset)),
197
+ shuffle=True,
198
+ generator=generator,
199
+ )
200
+
201
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
202
+ model = _make_model(workspace, hyperparameters).to(device)
203
+ if hyperparameters.optimizer.lower() == "sgd":
204
+ optimizer = torch.optim.SGD(
205
+ model.parameters(),
206
+ lr=hyperparameters.learning_rate,
207
+ weight_decay=hyperparameters.weight_decay,
208
+ )
209
+ else:
210
+ optimizer = torch.optim.Adam(
211
+ model.parameters(),
212
+ lr=hyperparameters.learning_rate,
213
+ weight_decay=hyperparameters.weight_decay,
214
+ )
215
+
216
+ class_weights: torch.Tensor | None = None
217
+ if hyperparameters.class_weighting:
218
+ count_by_class = np.bincount(
219
+ y_train, minlength=len(workspace.label_names)
220
+ ).astype(np.float32)
221
+ weights = len(y_train) / (len(count_by_class) * count_by_class)
222
+ class_weights = torch.from_numpy(weights).to(device)
223
+ criterion = nn.CrossEntropyLoss(weight=class_weights)
224
+
225
+ history: list[dict[str, float]] = []
226
+ best_state: dict[str, torch.Tensor] | None = None
227
+ best_loss = float("inf")
228
+ patience = 0
229
+ for epoch in range(hyperparameters.epochs):
230
+ model.train()
231
+ running_loss = 0.0
232
+ seen = 0
233
+ for gene_batch, dna_batch, label_batch in loader:
234
+ gene_batch = gene_batch.to(device)
235
+ dna_batch = dna_batch.to(device)
236
+ label_batch = label_batch.to(device)
237
+ optimizer.zero_grad(set_to_none=True)
238
+ logits = model(gene_batch, dna_batch)
239
+ loss = criterion(logits, label_batch)
240
+ loss.backward()
241
+ optimizer.step()
242
+ running_loss += loss.item() * len(label_batch)
243
+ seen += len(label_batch)
244
+
245
+ validation_loss, _, validation_predictions = _evaluate(
246
+ model,
247
+ gene_validation,
248
+ dna_validation,
249
+ y_validation,
250
+ device,
251
+ )
252
+ epoch_row = {
253
+ "epoch": float(epoch + 1),
254
+ "training_loss": float(running_loss / max(seen, 1)),
255
+ "validation_loss": validation_loss,
256
+ "validation_accuracy": float(
257
+ accuracy_score(y_validation, validation_predictions)
258
+ ),
259
+ "validation_f1_macro": float(
260
+ f1_score(
261
+ y_validation,
262
+ validation_predictions,
263
+ average="macro",
264
+ zero_division=0,
265
+ )
266
+ ),
267
+ }
268
+ history.append(epoch_row)
269
+ if progress:
270
+ progress(
271
+ (epoch + 1) / hyperparameters.epochs,
272
+ (
273
+ f"Epoch {epoch + 1}/{hyperparameters.epochs} · "
274
+ f"validation F1 {epoch_row['validation_f1_macro']:.3f}"
275
+ ),
276
+ )
277
+
278
+ if validation_loss < best_loss - 1e-5:
279
+ best_loss = validation_loss
280
+ best_state = copy.deepcopy(model.state_dict())
281
+ patience = 0
282
+ else:
283
+ patience += 1
284
+ if patience >= hyperparameters.early_stopping_patience:
285
+ break
286
+
287
+ if best_state is not None:
288
+ model.load_state_dict(best_state)
289
+ validation_loss, probabilities, predictions = _evaluate(
290
+ model,
291
+ gene_validation,
292
+ dna_validation,
293
+ y_validation,
294
+ device,
295
+ )
296
+ confusion = confusion_matrix(
297
+ y_validation,
298
+ predictions,
299
+ labels=np.arange(len(workspace.label_names)),
300
+ )
301
+ metrics = {
302
+ "validation_loss": validation_loss,
303
+ "accuracy": float(accuracy_score(y_validation, predictions)),
304
+ "f1_macro": float(
305
+ f1_score(
306
+ y_validation, predictions, average="macro", zero_division=0
307
+ )
308
+ ),
309
+ "f1_weighted": float(
310
+ f1_score(
311
+ y_validation, predictions, average="weighted", zero_division=0
312
+ )
313
+ ),
314
+ "precision_macro": float(
315
+ precision_score(
316
+ y_validation, predictions, average="macro", zero_division=0
317
+ )
318
+ ),
319
+ "recall_macro": float(
320
+ recall_score(
321
+ y_validation, predictions, average="macro", zero_division=0
322
+ )
323
+ ),
324
+ "epochs_completed": len(history),
325
+ "training_samples": int(len(train_index)),
326
+ "validation_samples": int(len(validation_index)),
327
+ "device": str(device),
328
+ }
329
+ predictions_frame = pd.DataFrame(
330
+ {
331
+ "sample_row": validation_index,
332
+ "observed": [
333
+ workspace.label_names[value] for value in y_validation
334
+ ],
335
+ "predicted": [
336
+ workspace.label_names[value] for value in predictions
337
+ ],
338
+ }
339
+ )
340
+ for index, label in enumerate(workspace.label_names):
341
+ predictions_frame[f"P({label})"] = probabilities[:, index]
342
+
343
+ gene = workspace.gene_branch
344
+ dna = workspace.dna_branch
345
+ config = {
346
+ "format_version": 1,
347
+ "source_name": workspace.source_name,
348
+ "gene_features": gene.input_genes,
349
+ "dna_features": dna.input_genes,
350
+ "label_names": workspace.label_names,
351
+ "gene_hidden_genes": gene.hidden_genes,
352
+ "dna_hidden_genes": dna.hidden_genes,
353
+ "gene_pathways": gene.pathways,
354
+ "dna_pathways": dna.pathways,
355
+ "hyperparameters": asdict(hyperparameters),
356
+ "architecture": {
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(
366
+ model=model.cpu(),
367
+ gene_features=gene.input_genes,
368
+ dna_features=dna.input_genes,
369
+ label_names=workspace.label_names,
370
+ gene_mean=gene_mean,
371
+ gene_scale=gene_scale,
372
+ dna_mean=dna_mean,
373
+ dna_scale=dna_scale,
374
+ config=config,
375
+ metrics=metrics,
376
+ history=history,
377
+ )
378
+ return TrainingResult(
379
+ bundle=bundle,
380
+ validation_predictions=predictions_frame,
381
+ confusion=confusion,
382
+ )
383
+
384
+
385
+ def validate_prediction_frames(
386
+ gene_frame: pd.DataFrame,
387
+ dna_frame: pd.DataFrame,
388
+ bundle: ModelBundle,
389
+ ) -> tuple[np.ndarray, np.ndarray]:
390
+ if len(gene_frame) != len(dna_frame):
391
+ raise ValueError(
392
+ "Prediction gene-expression and DNA-methylation files must have "
393
+ "the same number of rows."
394
+ )
395
+ missing_gene = sorted(set(bundle.gene_features) - set(gene_frame.columns))
396
+ missing_dna = sorted(set(bundle.dna_features) - set(dna_frame.columns))
397
+ if missing_gene or missing_dna:
398
+ details = []
399
+ if missing_gene:
400
+ details.append(
401
+ "gene-expression: " + ", ".join(missing_gene[:8])
402
+ )
403
+ if missing_dna:
404
+ details.append("DNA-methylation: " + ", ".join(missing_dna[:8]))
405
+ raise ValueError(
406
+ "Prediction files are missing trained features (" + "; ".join(details) + ")."
407
+ )
408
+ gene_values = gene_frame.loc[:, bundle.gene_features].apply(
409
+ pd.to_numeric, errors="coerce"
410
+ )
411
+ dna_values = dna_frame.loc[:, bundle.dna_features].apply(
412
+ pd.to_numeric, errors="coerce"
413
+ )
414
+ if gene_values.isna().any().any() or dna_values.isna().any().any():
415
+ raise ValueError("Prediction inputs contain missing or non-numeric values.")
416
+ return (
417
+ _scale(gene_values.to_numpy(), bundle.gene_mean, bundle.gene_scale),
418
+ _scale(dna_values.to_numpy(), bundle.dna_mean, bundle.dna_scale),
419
+ )
420
+
421
+
422
+ def predict(
423
+ gene_frame: pd.DataFrame,
424
+ dna_frame: pd.DataFrame,
425
+ bundle: ModelBundle,
426
+ ) -> pd.DataFrame:
427
+ gene_values, dna_values = validate_prediction_frames(
428
+ gene_frame, dna_frame, bundle
429
+ )
430
+ bundle.model.eval()
431
+ with torch.no_grad():
432
+ logits = bundle.model(
433
+ torch.from_numpy(gene_values), torch.from_numpy(dna_values)
434
+ )
435
+ probabilities = torch.softmax(logits, dim=1).numpy()
436
+ predicted = probabilities.argmax(axis=1)
437
+ output = pd.DataFrame(
438
+ {
439
+ "sample_row": np.arange(len(gene_frame)),
440
+ "predicted_class": [
441
+ bundle.label_names[index] for index in predicted
442
+ ],
443
+ "confidence": probabilities.max(axis=1),
444
+ }
445
+ )
446
+ for index, label in enumerate(bundle.label_names):
447
+ output[f"P({label})"] = probabilities[:, index]
448
+ return output
449
+
450
+
451
+ def pathway_importance(bundle: ModelBundle) -> pd.DataFrame:
452
+ bundle.model.eval()
453
+ with torch.no_grad():
454
+ attention = bundle.model.pathway_attention()
455
+ records: list[dict[str, float | str]] = []
456
+ for branch_name, config_key in (
457
+ ("Gene expression", "gene_pathways"),
458
+ ("DNA methylation", "dna_pathways"),
459
+ ):
460
+ key = "gene_expression" if branch_name == "Gene expression" else "dna_methylation"
461
+ weights = attention[key].cpu().numpy()
462
+ pathways = bundle.config[config_key]
463
+ peak_attention = weights.max(axis=0)
464
+ entropy = -(
465
+ weights * np.log(np.clip(weights, 1e-12, None))
466
+ ).sum(axis=0)
467
+ for pathway, peak, entropy_value in zip(
468
+ pathways, peak_attention, entropy, strict=True
469
+ ):
470
+ records.append(
471
+ {
472
+ "branch": branch_name,
473
+ "pathway": pathway,
474
+ "peak_gene_attention": float(peak),
475
+ "attention_entropy": float(entropy_value),
476
+ }
477
+ )
478
+ return (
479
+ pd.DataFrame(records)
480
+ .sort_values("peak_gene_attention", ascending=False)
481
+ .reset_index(drop=True)
482
+ )
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements-local.txt
2
+ pytest>=8,<10
requirements-local.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ spaces
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=6,<7
2
+ huggingface-hub>=0.27,<2
3
+ numpy>=1.26,<3
4
+ pandas>=2.1,<3
5
+ plotly>=5.20,<7
6
+ pyarrow>=15,<24
7
+ safetensors>=0.4,<1
8
+ scikit-learn>=1.4,<2
9
+ scipy>=1.11,<2
10
+ torch>=2.8,<3
tests/test_core.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
10
+ from biolmnet.data import (
11
+ BranchPriors,
12
+ PreparedWorkspace,
13
+ attach_embeddings_and_pathways,
14
+ build_biological_mask,
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:
22
+ pdi = pd.DataFrame(
23
+ {"TF": ["A", "C", "outside"], "Target": ["B", "D", "A"]}
24
+ )
25
+ ppi = pd.DataFrame(
26
+ {
27
+ "protein1": ["A", "X", "B"],
28
+ "protein2": ["X", "C", "Y"],
29
+ "combined_score": [950, 950, 710],
30
+ }
31
+ )
32
+ branch = build_biological_mask(["A", "B", "C", "D"], pdi, ppi)
33
+
34
+ assert {"B", "D", "X"}.issubset(branch.hidden_genes)
35
+ x_index = branch.hidden_genes.index("X")
36
+ assert branch.biological_mask[0, x_index] > 0
37
+ assert branch.biological_mask[2, x_index] > 0
38
+ assert branch.pdi_edges == 2
39
+
40
+
41
+ def test_masked_linear_disconnects_unlisted_weights() -> None:
42
+ mask = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
43
+ layer = BioMaskedLinear(mask, bias=False)
44
+ values = torch.tensor([[2.0, 3.0]])
45
+ baseline = layer(values).detach().clone()
46
+ with torch.no_grad():
47
+ layer.weight[0, 1] = 10_000
48
+ layer.weight[1, 0] = -10_000
49
+ changed = layer(values).detach()
50
+ assert torch.allclose(baseline, changed)
51
+
52
+
53
+ def _tiny_workspace() -> PreparedWorkspace:
54
+ rng = np.random.default_rng(7)
55
+ sample_count = 48
56
+ labels = np.repeat(np.array([0, 1]), sample_count // 2)
57
+ gene_values = rng.normal(size=(sample_count, 4)).astype(np.float32)
58
+ dna_values = rng.normal(size=(sample_count, 4)).astype(np.float32)
59
+ gene_values[:, 0] += labels * 1.5
60
+ dna_values[:, 1] -= labels * 1.2
61
+ input_genes = ["A", "B", "C", "D"]
62
+ hidden_genes = ["A", "B", "C"]
63
+ biological = np.array(
64
+ [
65
+ [1.0, 1.0, 0.0],
66
+ [0.0, 1.0, 1.0],
67
+ [1.0, 0.0, 1.0],
68
+ [0.0, 1.0, 0.0],
69
+ ],
70
+ dtype=np.float32,
71
+ )
72
+ pathway_mapping = pd.DataFrame(
73
+ {
74
+ "SYMBOL": ["A", "B", "B", "C"],
75
+ "PathwayID": ["hsa1", "hsa1", "hsa2", "hsa2"],
76
+ }
77
+ )
78
+ embeddings = deterministic_gene_embeddings(hidden_genes, dimensions=8)
79
+
80
+ def branch() -> BranchPriors:
81
+ value = BranchPriors(
82
+ input_genes=input_genes.copy(),
83
+ hidden_genes=hidden_genes.copy(),
84
+ biological_mask=biological.copy(),
85
+ pdi_edges=3,
86
+ ppi_edges=4,
87
+ )
88
+ attach_embeddings_and_pathways(
89
+ value,
90
+ embeddings,
91
+ pathway_mapping,
92
+ precomputed_significant=True,
93
+ )
94
+ return value
95
+
96
+ return PreparedWorkspace(
97
+ gene_expression=gene_values,
98
+ dna_methylation=dna_values,
99
+ labels=labels,
100
+ label_names=["control", "case"],
101
+ gene_branch=branch(),
102
+ dna_branch=branch(),
103
+ source_name="unit test",
104
+ )
105
+
106
+
107
+ def test_model_forward_probabilistic_shape() -> None:
108
+ workspace = _tiny_workspace()
109
+ gene = workspace.gene_branch
110
+ dna = workspace.dna_branch
111
+ model = BioLMNet(
112
+ torch.from_numpy(gene.biological_mask),
113
+ torch.from_numpy(dna.biological_mask),
114
+ torch.from_numpy(gene.embeddings),
115
+ torch.from_numpy(dna.embeddings),
116
+ torch.from_numpy(gene.pathway_mask),
117
+ torch.from_numpy(dna.pathway_mask),
118
+ n_classes=2,
119
+ projection_dim=4,
120
+ fusion_dim=3,
121
+ dropout=0.0,
122
+ )
123
+ logits = model(torch.randn(5, 4), torch.randn(5, 4))
124
+ assert logits.shape == (5, 2)
125
+ assert torch.allclose(
126
+ model.gene_branch.pathway_attention.attention_weights().sum(dim=0),
127
+ torch.ones(2),
128
+ )
129
+
130
+
131
+ def test_training_artifact_roundtrip(tmp_path) -> None:
132
+ workspace = _tiny_workspace()
133
+ result = train(
134
+ workspace,
135
+ Hyperparameters(
136
+ epochs=3,
137
+ batch_size=8,
138
+ projection_dim=4,
139
+ fusion_dim=3,
140
+ dropout=0.0,
141
+ early_stopping_patience=3,
142
+ ),
143
+ )
144
+ restored_from_process_boundary = pickle.loads(pickle.dumps(result.bundle))
145
+ assert restored_from_process_boundary.label_names == ["control", "case"]
146
+ artifact = save_bundle(result.bundle, tmp_path / "model.zip")
147
+ restored = load_bundle(artifact)
148
+ gene_frame = pd.DataFrame(
149
+ workspace.gene_expression[:5], columns=restored.gene_features
150
+ )
151
+ dna_frame = pd.DataFrame(
152
+ workspace.dna_methylation[:5], columns=restored.dna_features
153
+ )
154
+ before = predict(gene_frame, dna_frame, result.bundle)
155
+ after = predict(gene_frame, dna_frame, restored)
156
+ probability_columns = [column for column in before if column.startswith("P(")]
157
+ np.testing.assert_allclose(
158
+ before[probability_columns].to_numpy(),
159
+ after[probability_columns].to_numpy(),
160
+ atol=1e-6,
161
+ )
162
+
163
+
164
+ def test_zerogpu_duration_estimator_is_bounded_and_scales() -> None:
165
+ from app import estimate_training_duration
166
+
167
+ workspace = _tiny_workspace()
168
+ common = (workspace, 16, 0.001, 0.01, 0.3, 64, 12, 0.2, "Adam", True)
169
+ short = estimate_training_duration(common[0], 10, *common[1:])
170
+ long = estimate_training_duration(common[0], 200, *common[1:])
171
+ assert 30 <= short <= 300
172
+ assert short <= long <= 300