Spaces:
Sleeping
Sleeping
Add ZeroGPU-enabled BioLM-NET workbench
Browse files- .gitignore +7 -0
- LICENSE +94 -0
- README.md +121 -7
- app.py +984 -0
- biolmnet/__init__.py +5 -0
- biolmnet/artifacts.py +145 -0
- biolmnet/data.py +470 -0
- biolmnet/model.py +198 -0
- biolmnet/training.py +482 -0
- requirements-dev.txt +2 -0
- requirements-local.txt +2 -0
- requirements.txt +10 -0
- tests/test_core.py +172 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 > 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
|