Spaces:
Sleeping
Sleeping
devjas1 commited on
Commit ·
4a0e21d
0
Parent(s):
Initial Release: Polymer Aging With ML [Standalone Appliance]
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +49 -0
- .gitattributes +37 -0
- .gitignore +59 -0
- ARCHITECTURE.md +504 -0
- Dockerfile +44 -0
- LICENSE +201 -0
- README.md +177 -0
- backend/__init__.py +1 -0
- backend/config.py +4 -0
- backend/main.py +598 -0
- backend/models/__init__.py +0 -0
- backend/models/enhanced_cnn.py +410 -0
- backend/models/figure2_cnn.py +77 -0
- backend/models/registry.py +246 -0
- backend/models/resnet18_vision.py +82 -0
- backend/models/resnet_cnn.py +73 -0
- backend/models/weights/efficient_cnn_model.pth +3 -0
- backend/models/weights/enhanced_cnn_model.pth +3 -0
- backend/models/weights/figure2_model.pth +3 -0
- backend/models/weights/hybrid_net_model.pth +3 -0
- backend/models/weights/resnet18vision_model.pth +3 -0
- backend/models/weights/resnet_model.pth +3 -0
- backend/pydantic_models.py +353 -0
- backend/registry.py +237 -0
- backend/service.py +331 -0
- backend/service.py # (edit +0 -0
- backend/tests/test_api.py +17 -0
- backend/tests/test_service.py +54 -0
- backend/utils/confidence.py +97 -0
- backend/utils/enhanced_ml_service.py +317 -0
- backend/utils/errors.py +36 -0
- backend/utils/model_manager.py +177 -0
- backend/utils/multifile.py +480 -0
- backend/utils/performance.py +101 -0
- backend/utils/prepare_data.py +76 -0
- backend/utils/preprocessing.py +331 -0
- backend/utils/preprocessing_fixed.py +301 -0
- backend/utils/raman_util.py +46 -0
- backend/utils/seeds.py +157 -0
- backend/utils/spectrum_preprocessor.py +242 -0
- backend/utils/train.py +163 -0
- backend/utils/training_engine.py +161 -0
- backend/utils/training_engine_enhanced.py +431 -0
- backend/utils/training_manager.py +638 -0
- backend/utils/training_types.py +128 -0
- frontend/README.md +120 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +65 -0
- frontend/public/index.html +40 -0
- frontend/public/robots.txt +3 -0
.dockerignore
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Exclude Git history (huge security risk if leaked into an image)
|
| 2 |
+
.git/
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Exclude secrets, credentials and local environment configs
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
*.pem
|
| 9 |
+
*.key
|
| 10 |
+
*.cert
|
| 11 |
+
*.p12
|
| 12 |
+
credentials.json
|
| 13 |
+
|
| 14 |
+
# Exclude local Python environments and cache
|
| 15 |
+
__pycache__/
|
| 16 |
+
*.py[cod]
|
| 17 |
+
*$py.class
|
| 18 |
+
.venv/
|
| 19 |
+
venv/
|
| 20 |
+
env/
|
| 21 |
+
ENV/
|
| 22 |
+
|
| 23 |
+
# Exclude local Node modules and frontend builds (they are rebuilt in Docker)
|
| 24 |
+
node_modules/
|
| 25 |
+
frontend/node_modules/
|
| 26 |
+
frontend/build/
|
| 27 |
+
frontend/dist/
|
| 28 |
+
.eslintcache
|
| 29 |
+
|
| 30 |
+
# Exclude logs, local data, and local test outputs
|
| 31 |
+
*.log
|
| 32 |
+
tmp/
|
| 33 |
+
backend/tests/
|
| 34 |
+
*.sqlite3
|
| 35 |
+
*.db
|
| 36 |
+
|
| 37 |
+
# Exclude IDE and OS files (prevents dev machine details leakage)
|
| 38 |
+
.vscode/
|
| 39 |
+
.idea/
|
| 40 |
+
.DS_Store
|
| 41 |
+
Thumbs.db
|
| 42 |
+
*.swp
|
| 43 |
+
|
| 44 |
+
# Optional: Exclude documentation to reduce build context size
|
| 45 |
+
*.md
|
| 46 |
+
|
| 47 |
+
# Exclude Dockerfile itself
|
| 48 |
+
Dockerfile
|
| 49 |
+
.dockerignore
|
.gitattributes
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
backend/models/weights/*.pth filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Environments
|
| 7 |
+
.env
|
| 8 |
+
.venv
|
| 9 |
+
env/
|
| 10 |
+
venv/
|
| 11 |
+
ENV/
|
| 12 |
+
|
| 13 |
+
# Logs
|
| 14 |
+
*.log
|
| 15 |
+
tmp/logs/
|
| 16 |
+
backend/tests/test_output/
|
| 17 |
+
|
| 18 |
+
# Node/Frontend
|
| 19 |
+
node_modules/
|
| 20 |
+
frontend/node_modules/
|
| 21 |
+
frontend/build/
|
| 22 |
+
.eslintcache
|
| 23 |
+
|
| 24 |
+
# OS-specific
|
| 25 |
+
.DS_Store
|
| 26 |
+
Thumbs.db
|
| 27 |
+
|
| 28 |
+
# Large Model Weights (Safety catch - even though we use LFS)
|
| 29 |
+
# backend/models/weights/*.pth # Commented out because we DO want to track them via LFS
|
| 30 |
+
|
| 31 |
+
# Security & Secrets
|
| 32 |
+
.env*
|
| 33 |
+
*.pem
|
| 34 |
+
*.key
|
| 35 |
+
*.cert
|
| 36 |
+
*.p12
|
| 37 |
+
*secret*
|
| 38 |
+
credentials.json
|
| 39 |
+
|
| 40 |
+
# Local Databases
|
| 41 |
+
*.sqlite3
|
| 42 |
+
*.db
|
| 43 |
+
|
| 44 |
+
# IDE & Editor Settings (Can leak local paths)
|
| 45 |
+
.vscode/
|
| 46 |
+
.idea/
|
| 47 |
+
*.swp
|
| 48 |
+
*.swo
|
| 49 |
+
|
| 50 |
+
# Coverage and Reports
|
| 51 |
+
.coverage
|
| 52 |
+
htmlcov/
|
| 53 |
+
coverage.xml
|
| 54 |
+
|
| 55 |
+
Makefile
|
| 56 |
+
tmp/
|
| 57 |
+
/tmp
|
| 58 |
+
*.log
|
| 59 |
+
log
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Polymer Aging ML (React + FastAPI)
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
AI-Driven Polymer Aging Prediction and Classification (v0.2)
|
| 14 |
+
============================================================
|
| 15 |
+
|
| 16 |
+
[](LICENSE)
|
| 17 |
+

|
| 18 |
+

|
| 19 |
+

|
| 20 |
+

|
| 21 |
+
|
| 22 |
+
This web application classifies the degradation state of polymers using **Raman and FTIR spectroscopy** and deep learning.
|
| 23 |
+
It features a modern **React frontend** with a **FastAPI backend** for production-ready deployment with full scientific provenance tracking.
|
| 24 |
+
This document serves as a Good Reference Note (GRN) baseline for the `ml-polymer-recycling` project. It provides a comprehensive snapshot of the system architecture, component interactions, and data flow as of this version.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## Current Scope
|
| 29 |
+
|
| 30 |
+
## 1. Project Overview
|
| 31 |
+
|
| 32 |
+
- **Modalities**: Raman & FTIR spectroscopy
|
| 33 |
+
- **Input Formats**: `.txt`, `.csv`, `.json` (with auto-detection)
|
| 34 |
+
- **Models**: Figure2CNN (baseline), ResNet1D, ResNet18Vision, Custom CNNs (Enhanced, Efficient, Hybrid)
|
| 35 |
+
- **Task**: Binary classification — Stable vs Weathered polymers
|
| 36 |
+
- **Features**:
|
| 37 |
+
- Single-spectrum + Batch Spectrum Analysis
|
| 38 |
+
- Multi-model comparison
|
| 39 |
+
- Performance tracking dashboard
|
| 40 |
+
- **Full provenance tracking** (QC checks, preprocessing parameters, model metadata)
|
| 41 |
+
- **RESTful API** for programmatic access
|
| 42 |
+
- **Architecture**: React + FastAPI + PyTorch
|
| 43 |
+
This project is a full-stack web application that uses Artificial Intelligence to analyze chemical data from plastics and polymers. Users can upload their own scientific data (a spectrum from Raman or FTIR spectroscopy) and the AI will predict whether the material is **stable** (unweathered) or has been **weathered** (degraded).
|
| 44 |
+
|
| 45 |
+
The platform also provides advanced features:
|
| 46 |
+
|
| 47 |
+
- **Model Comparison:** Allows users to run an analysis using multiple different AI models and compare their predictions.
|
| 48 |
+
- **AI Explainability:** Provides a deep dive into _why_ the AI made a specific decision, highlighting the spectral regions that most influenced the outcome.
|
| 49 |
+
- **Performance Tracking:** Offers a dashboard to monitor the health of the backend system and the performance metrics of the loaded AI models.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Architecture
|
| 54 |
+
|
| 55 |
+
## 2. Table of Contents
|
| 56 |
+
|
| 57 |
+
**Modern single-container deployment with complete separation of concerns:**
|
| 58 |
+
|
| 59 |
+
- **Frontend**: React TypeScript application with Material Design-inspired UI
|
| 60 |
+
- **Backend**: FastAPI with comprehensive ML inference pipeline
|
| 61 |
+
- **API Contract**: Strict Pydantic models for all request/response validation
|
| 62 |
+
- **Scientific Fidelity**: Exact preservation of original Streamlit ML logic
|
| 63 |
+
- **Full Provenance**: Every prediction includes QC checks, preprocessing details, and model metadata
|
| 64 |
+
- **Single Port**: Both frontend and API served on the same port for Hugging Face Spaces compatibility
|
| 65 |
+
- 1. Project Overview
|
| 66 |
+
- 2. Table of Contents
|
| 67 |
+
- 3. Technology Stack
|
| 68 |
+
- 4. Project Structure
|
| 69 |
+
- 5. Setup and Installation
|
| 70 |
+
- 6. Architectural Deep Dive
|
| 71 |
+
- 6.1. React Component Architecture
|
| 72 |
+
- 6.2. API Endpoint Layer
|
| 73 |
+
- 6.3. API Data Contracts
|
| 74 |
+
- 6.4. ML Inference Service
|
| 75 |
+
- 6.5. Transparent AI & Explainability
|
| 76 |
+
- 6.6. Model Registry
|
| 77 |
+
- 7. Component & API Mapping
|
| 78 |
+
- 7.1. Frontend Component Guide
|
| 79 |
+
- 8. Data Flow Example: Standard Analysis
|
| 80 |
+
- 9. Developer Workflow Guide
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## How to Use
|
| 85 |
+
|
| 86 |
+
## 3. Technology Stack
|
| 87 |
+
|
| 88 |
+
The application provides three main analysis modes:
|
| 89 |
+
|
| 90 |
+
### 1. **Standard Analysis**
|
| 91 |
+
|
| 92 |
+
- Upload a single spectrum file (`.txt`, `.csv`, `.json`)
|
| 93 |
+
- Choose a model and spectroscopy modality
|
| 94 |
+
- Get detailed predictions with full provenance metadata
|
| 95 |
+
- View spectrum processing visualization
|
| 96 |
+
|
| 97 |
+
### 2. **Model Comparison**
|
| 98 |
+
|
| 99 |
+
- Upload a single spectrum file
|
| 100 |
+
- Compare predictions from all available models
|
| 101 |
+
- View consensus predictions and model agreement metrics
|
| 102 |
+
- Analyze confidence variance across models
|
| 103 |
+
|
| 104 |
+
### 3. **Performance Tracking**
|
| 105 |
+
|
| 106 |
+
- Monitor system health and model performance
|
| 107 |
+
- View detailed model specifications and availability
|
| 108 |
+
- Track memory usage and processing capabilities
|
| 109 |
+
|
| 110 |
+
### Supported Input
|
| 111 |
+
|
| 112 |
+
- Plaintext `.txt`, `.csv`, or `.json` files
|
| 113 |
+
- Data can be space-, comma-, or tab-separated
|
| 114 |
+
- Comment lines (`#`, `%`) are ignored
|
| 115 |
+
- Automatic format detection and resampling to standard length
|
| 116 |
+
|
| 117 |
+
| Area | Technology | Description |
|
| 118 |
+
| :------- | :--------------------- | :------------------------------------------------------------------------ |
|
| 119 |
+
| Backend | **Python 3.10+** | Core programming language. |
|
| 120 |
+
| | **FastAPI** | High-performance web framework for building the API. |
|
| 121 |
+
| | **Pydantic** | Data validation and settings management (defines API Data Contracts). |
|
| 122 |
+
| | **PyTorch** | The deep learning framework used to build and run the AI models. |
|
| 123 |
+
| | **NumPy** & **SciPy** | Libraries for numerical operations and scientific data processing. |
|
| 124 |
+
| | **Uvicorn** | ASGI server that runs the FastAPI application. |
|
| 125 |
+
| Frontend | **TypeScript** | Superset of JavaScript that adds static typing for robustness. |
|
| 126 |
+
| | **React** | A component-based library for building the user interface. |
|
| 127 |
+
| | **Recharts** | A composable charting library for visualizing spectrum data. |
|
| 128 |
+
| | **openapi-typescript** | Tool to auto-generate TypeScript types from the backend's OpenAPI schema. |
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## API Documentation
|
| 133 |
+
|
| 134 |
+
The API is documented using the OpenAPI standard. When the application is running, you can access the live, interactive documentation (Swagger UI) to explore and test all available endpoints. This is the single source of truth for the API.
|
| 135 |
+
|
| 136 |
+
- **URL**: [`http://localhost:8000/api/v1/docs`](http://localhost:8000/api/v1/docs)
|
| 137 |
+
|
| 138 |
+
## 4. Project Structure
|
| 139 |
+
|
| 140 |
+
The project is a monorepo containing both the `backend` and `frontend` in a single directory.
|
| 141 |
+
|
| 142 |
+
```text
|
| 143 |
+
ml-polymer-recycling/
|
| 144 |
+
├── backend/
|
| 145 |
+
│ ├── main.py # FastAPI app: API endpoints, middleware, static file serving.
|
| 146 |
+
│ ├── service.py # Core MLInferenceService: handles all analysis logic.
|
| 147 |
+
│ ├── models.py # Pydantic models: defines the API data contracts.
|
| 148 |
+
│ ├── utils/
|
| 149 |
+
│ │ ├── preprocessing.py # Spectrum cleaning and standardization functions.
|
| 150 |
+
│ │ └── enhanced_ml_service.py # Logic for the explainability features.
|
| 151 |
+
│ └── models/
|
| 152 |
+
│ ├── registry.py # The Model Registry for managing different AI models.
|
| 153 |
+
│ └── figure2_cnn.py # Example implementation of a CNN model.
|
| 154 |
+
├── frontend/
|
| 155 |
+
│ ├── src/
|
| 156 |
+
│ │ ├── App.tsx # Main React component, manages layout and tabs.
|
| 157 |
+
│ │ ├── apiClient.ts # Central API client for all frontend-backend communication.
|
| 158 |
+
│ │ ├── components/ # Directory for all reusable React components.
|
| 159 |
+
│ │ │ ├── Sidebar.tsx
|
| 160 |
+
│ │ │ ├── StandardAnalysis.tsx
|
| 161 |
+
│ │ │ ├── ResultsDisplay.tsx
|
| 162 |
+
│ │ │ └── ... (other components)
|
| 163 |
+
│ │ └── types/
|
| 164 |
+
│ │ └── api.ts # Auto-generated TypeScript types from the backend API.
|
| 165 |
+
│ ├── public/
|
| 166 |
+
│ └── package.json # Frontend dependencies and scripts.
|
| 167 |
+
├── main.py # Main entry point to start the application server.
|
| 168 |
+
└── openapi-schema.json # The OpenAPI specification generated by FastAPI.
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
## **API Documentation**: Available at `/api/docs` when running
|
| 172 |
+
|
| 173 |
+
## 5. Setup and Installation
|
| 174 |
+
|
| 175 |
+
### Prerequisites
|
| 176 |
+
|
| 177 |
+
- **Python 3.10+** and `pip`
|
| 178 |
+
- **Node.js 16+** and `npm`
|
| 179 |
+
|
| 180 |
+
### 1. Backend Setup
|
| 181 |
+
|
| 182 |
+
From the project root directory, install the required Python packages:
|
| 183 |
+
|
| 184 |
+
```bash
|
| 185 |
+
pip install -r backend/requirements.txt
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### 2. Frontend Setup
|
| 189 |
+
|
| 190 |
+
Navigate to the frontend directory and install the Node.js dependencies:
|
| 191 |
+
|
| 192 |
+
```bash
|
| 193 |
+
cd frontend
|
| 194 |
+
npm install
|
| 195 |
+
cd ..
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
### 3. Running the Application
|
| 199 |
+
|
| 200 |
+
The application is designed for a single-container deployment. The `main.py` script at the root will automatically build the frontend (if needed) and start the backend server, which also serves the frontend files.
|
| 201 |
+
|
| 202 |
+
From the project root directory, run:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
python main.py
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
You can now access the application at `http://localhost:8000`.
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## 🔧 Development & Deployment
|
| 213 |
+
|
| 214 |
+
## 6. Architectural Deep Dive
|
| 215 |
+
|
| 216 |
+
### Local Development
|
| 217 |
+
|
| 218 |
+
This section provides a high-level overview of the core architectural concepts that power this application.
|
| 219 |
+
|
| 220 |
+
For the frontend to connect to the backend, you'll need to set an environment variable.
|
| 221 |
+
|
| 222 |
+
### 6.1. React Component Architecture
|
| 223 |
+
|
| 224 |
+
1. **Configure API URL**:
|
| 225 |
+
Create a file named `.env.development` in the `frontend` directory with the following content:
|
| 226 |
+
` REACT_APP_API_BASE_URL=http://localhost:8000`
|
| 227 |
+
The frontend user interface is built using React. The core idea is to build everything from small, independent, and reusable pieces called **components**.
|
| 228 |
+
|
| 229 |
+
2. **Run Backend**:
|
| 230 |
+
```bash
|
| 231 |
+
# Backend development
|
| 232 |
+
python main.py
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
- **Independent:** A component manages its own logic and appearance (e.g., `SpectrumChart`).
|
| 236 |
+
- **Reusable:** A component can be used in many different places (e.g., a `Button` or `Spinner`).
|
| 237 |
+
- **Composable:** Simple components are combined to build more complex structures.
|
| 238 |
+
|
| 239 |
+
3. **Run Frontend**:
|
| 240 |
+
`bash
|
| 241 |
+
# Frontend development (separate terminal)
|
| 242 |
+
cd frontend
|
| 243 |
+
npm start
|
| 244 |
+
`The main file,`frontend/src/App.tsx`, assembles the page layout by composing high-level components like `<Header />`, `<Sidebar />`, and the main tab components (`<StandardAnalysis />`, `<ModelComparison />`, etc.). It uses a **state** variable (`activeTab`) to manage which view is currently visible, making the UI interactive.
|
| 245 |
+
|
| 246 |
+
**Build for Production**:
|
| 247 |
+
|
| 248 |
+
```bash
|
| 249 |
+
# Build for production
|
| 250 |
+
cd frontend
|
| 251 |
+
npm run build
|
| 252 |
+
python main.py # Serves both frontend and API
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
### 6.2. API Endpoint Layer
|
| 256 |
+
|
| 257 |
+
### Docker Deployment
|
| 258 |
+
|
| 259 |
+
The frontend (in the browser) cannot run the AI models directly. It communicates with the backend via an **API (Application Programming Interface)**. The API defines a "menu" of services the backend offers at specific URLs, called **endpoints**.
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
# Single-container deployment
|
| 263 |
+
docker build -t polymer-aging-ml .
|
| 264 |
+
docker run -p 8000:8000 polymer-aging-ml
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
- `GET /api/v1/models`: Asks the backend for the list of available models.
|
| 268 |
+
- `POST /api/v1/analyze`: Sends a spectrum to the backend to be analyzed.
|
| 269 |
+
|
| 270 |
+
### Hugging Face Spaces
|
| 271 |
+
|
| 272 |
+
The backend uses the **FastAPI** framework to create these endpoints. An endpoint's job is to manage communication, not to do the core work. It receives a request, delegates the task to a service (like the `MLInferenceService`), and returns the final response.
|
| 273 |
+
|
| 274 |
+
The application is designed for single-container deployment on Hugging Face Spaces:
|
| 275 |
+
|
| 276 |
+
### 6.3. API Data Contracts
|
| 277 |
+
|
| 278 |
+
- Uses Docker SDK with port 8000
|
| 279 |
+
- Serves React frontend and FastAPI backend on the same port
|
| 280 |
+
- Optimized build process with multi-stage Docker builds
|
| 281 |
+
To prevent miscommunication, the frontend and backend agree on a strict **Data Contract**—a blueprint that defines the exact structure of the data they exchange.
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
- **Backend (Pydantic):** In `backend/models.py`, we use Pydantic models to define the expected structure of requests and responses. FastAPI uses these models to automatically validate all incoming data.
|
| 286 |
+
- **Frontend (TypeScript):** In `frontend/src/types/api.ts`, we have TypeScript interfaces that are auto-generated from the backend's OpenAPI schema. This ensures the frontend always creates data that conforms to the contract.
|
| 287 |
+
|
| 288 |
+
## Contributors
|
| 289 |
+
|
| 290 |
+
This dual-blueprint system guarantees that the "data door" built by the frontend will always fit the "data doorframe" expected by the backend.
|
| 291 |
+
|
| 292 |
+
- Dr. Sanmukh Kuppannagari (Mentor)
|
| 293 |
+
- Dr. Metin Karailyan (Mentor)
|
| 294 |
+
- Jaser Hasan (Author/Developer)
|
| 295 |
+
|
| 296 |
+
### 6.4. ML Inference Service
|
| 297 |
+
|
| 298 |
+
## Model Credit
|
| 299 |
+
|
| 300 |
+
The `MLInferenceService` (in `backend/service.py`) is the core scientific engine of the application. It encapsulates the entire analysis workflow:
|
| 301 |
+
|
| 302 |
+
Baseline model inspired by:
|
| 303 |
+
|
| 304 |
+
1. **Receive** the raw spectrum data.
|
| 305 |
+
2. **Preprocess** the data (baseline correction, resampling, normalization).
|
| 306 |
+
3. **Load** the correct AI model from the Model Registry.
|
| 307 |
+
4. **Run Inference** to get a prediction from the model.
|
| 308 |
+
5. **Package the Result** with rich **provenance metadata**, documenting every step of the process for scientific validity and reproducibility.
|
| 309 |
+
|
| 310 |
+
Neo, E.R.K., Low, J.S.C., Goodship, V., Debattista, K. (2023).
|
| 311 |
+
_Deep learning for chemometric analysis of plastic spectral data from infrared and Raman databases._
|
| 312 |
+
_Resources, Conservation & Recycling_, **188**, 106718.
|
| 313 |
+
https://doi.org/10.1016/j.resconrec.2022.106718
|
| 314 |
+
This service separates the complex scientific logic from the web-serving logic of the API endpoints.
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
### 6.5. Transparent AI & Explainability
|
| 319 |
+
|
| 320 |
+
## 🔗 Links
|
| 321 |
+
|
| 322 |
+
To avoid being a "black box," the system includes an explainability engine (`enhanced_ml_service.py`). When a user requests an explanation, this service goes beyond a simple prediction and provides deeper insights:
|
| 323 |
+
|
| 324 |
+
- **Live App**: Hugging Face Space
|
| 325 |
+
- **GitHub Repo**: ml-polymer-recycling
|
| 326 |
+
- **Feature Importance:** It identifies which parts of the spectrum were most influential in the model's decision.
|
| 327 |
+
- **Uncertainty Quantification:** It can estimate how confident the model is and why.
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
This turns the AI from a simple calculator into an expert consultant, making the results more trustworthy and useful for scientific research.
|
| 332 |
+
|
| 333 |
+
Technical Architecture
|
| 334 |
+
|
| 335 |
+
### 6.6. Model Registry
|
| 336 |
+
|
| 337 |
+
**Production-ready architecture with complete separation of concerns:**
|
| 338 |
+
To manage a growing collection of different AI models, the project uses a **Model Registry** (`models/registry.py`). This acts as a central "toolbox" or catalog for all available model architectures.
|
| 339 |
+
|
| 340 |
+
### Frontend (React TypeScript)
|
| 341 |
+
|
| 342 |
+
Instead of hard-coding a specific model, the `MLInferenceService` can ask the registry for a model by its simple name (e.g., `"figure2"` or `"resnet"`). The registry handles the details of finding and constructing the correct model object. This makes the system highly flexible and easy to extend with new models.
|
| 343 |
+
|
| 344 |
+
- **UI Framework**: React 18 with TypeScript for type safety
|
| 345 |
+
- **Styling**: Custom CSS matching original Streamlit design language
|
| 346 |
+
- **Charts**: Recharts for spectrum visualization
|
| 347 |
+
- **File Upload**: React Dropzone for drag-and-drop functionality
|
| 348 |
+
- **API Client**: Axios with comprehensive error handling
|
| 349 |
+
- **Responsive Design**: Mobile-friendly interface
|
| 350 |
+
|
| 351 |
+
---
|
| 352 |
+
|
| 353 |
+
### Backend (FastAPI)
|
| 354 |
+
|
| 355 |
+
## 7. Component & API Mapping
|
| 356 |
+
|
| 357 |
+
- **API Framework**: FastAPI with automatic OpenAPI documentation
|
| 358 |
+
- **ML Pipeline**: Preserved original PyTorch inference logic
|
| 359 |
+
- **Validation**: Pydantic models for strict request/response contracts
|
| 360 |
+
- **Provenance**: Complete metadata tracking for scientific reproducibility
|
| 361 |
+
- **Performance**: Async processing with memory management
|
| 362 |
+
- **Models**: Registry pattern for dynamic model loading
|
| 363 |
+
|
| 364 |
+
### 7.1. Frontend Component Guide
|
| 365 |
+
|
| 366 |
+
### Scientific Fidelity
|
| 367 |
+
|
| 368 |
+
| Component (`/src/components/`) | Description | Key Interactions |
|
| 369 |
+
| :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------ |
|
| 370 |
+
| **`App.tsx`** | The main application shell. Manages the overall layout and which tab is currently active. Holds the global `spectrumData` state. | Renders `Sidebar`, `Header`, and the active tab component. Lifts state up from `StandardAnalysis`. |
|
| 371 |
+
| `Header.tsx` | A simple, static component that displays the application title and subtitle. | None. |
|
| 372 |
+
| `Sidebar.tsx` | Allows the user to select the AI model and spectroscopy modality (`raman`/`ftir`). Displays detailed information about the selected model. | Fetches model list via `apiClient.getModels()`. Updates `selectedModel` and `modality` state in `App.tsx`. |
|
| 373 |
+
| `StandardAnalysis.tsx` | The primary analysis tab. Manages file upload, analysis execution, and displaying results for a single spectrum. | Uses `react-dropzone` for uploads. Calls `apiClient.uploadSpectrum()` and `apiClient.analyzeSpectrum()`. |
|
| 374 |
+
| `ModelComparison.tsx` | UI for selecting multiple models to compare against a single spectrum. Fetches available models and allows user selection. | Calls `apiClient.compareModels()`. **Note:** The UI for displaying comparison results is not yet implemented. |
|
| 375 |
+
| `PerformanceTracking.tsx` | A dashboard that displays system health and detailed performance metrics for all available models. | Fetches data by calling `apiClient.getSystemInfo()`. |
|
| 376 |
+
| `ExplainabilityPanel.tsx` | The "AI Explainability" tab. Allows the user to run an analysis that generates feature importance and other insights. | Uses the shared `spectrumData` from `App.tsx`. Calls `apiClient.explainSpectrum()`. |
|
| 377 |
+
| `ResultsDisplay.tsx` | A detailed component that takes a `PredictionResult` object and renders all its information in a user-friendly format. | Renders class probabilities, performance metrics, and metadata. Uses `SpectrumChart` for visualization. |
|
| 378 |
+
| `SpectrumChart.tsx` | A reusable chart component that visualizes spectrum data using the `recharts` library. Can display original and processed spectra. | Receives `SpectrumData` as props and renders a `LineChart`. |
|
| 379 |
+
|
| 380 |
+
- **Exact Preservation**: All ML inference logic preserved from original Streamlit app
|
| 381 |
+
- **Deterministic Outputs**: Identical results to original application
|
| 382 |
+
- **Quality Control**: Comprehensive spectrum validation and QC checks
|
| 383 |
+
- **Preprocessing**: Complete preprocessing pipeline with metadata tracking
|
| 384 |
+
- **Model Metadata**: Full model provenance including training metrics
|
| 385 |
+
|
| 386 |
+
### 7.2. Backend API Endpoint Guide
|
| 387 |
+
|
| 388 |
+
### Deployment
|
| 389 |
+
|
| 390 |
+
| API Endpoint (`/api/v1/...`) | Method | Description | Service Function Called (backend) |
|
| 391 |
+
| :---------------------------- | :----- | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- |
|
| 392 |
+
| `/api/v1/system` | `GET` | Retrieves comprehensive system information, including loaded models, performance, and health metrics. | `ml_service.get_system_info()` |
|
| 393 |
+
| `/api/v1/models` | `GET` | Returns a list of all available models and their metadata. | `ml_service.get_available_models()` |
|
| 394 |
+
| `/api/v1/upload` | `POST` | Uploads a spectrum file (`.txt`, `.csv`, `.json`) and parses it into the standard `SpectrumData` format. | `utils.parse_spectrum_data()` (indirectly) |
|
| 395 |
+
| `/api/v1/analyze` | `POST` | Analyzes a single spectrum using a specified model and returns a detailed `PredictionResult`. | `ml_service.run_inference()` |
|
| 396 |
+
| `/api/v1/compare` | `POST` | Compares multiple models on a single spectrum and returns a `ComparisonResult`. | `ml_service.run_inference()` (in a loop) |
|
| 397 |
+
| `/api/v1/explain` | `POST` | Analyzes a spectrum and returns an enriched result with feature importance and other explainability data. | `enhanced_ml_service.py: predict_with_explanation()` |
|
| 398 |
+
|
| 399 |
+
- **Single Container**: React frontend and FastAPI backend in one Docker image
|
| 400 |
+
- **Multi-stage Build**: Optimized Docker builds for production
|
| 401 |
+
- **Static Serving**: FastAPI serves React static files efficiently
|
| 402 |
+
- **Health Checks**: Comprehensive monitoring and health endpoints
|
| 403 |
+
- **Port Unification**: Both UI and API on port 8000 for Hugging Face Spaces
|
| 404 |
+
|
| 405 |
+
---
|
| 406 |
+
|
| 407 |
+
## 8. Data Flow Example: Standard Analysis
|
| 408 |
+
|
| 409 |
+
## Notable Features in v0.2
|
| 410 |
+
|
| 411 |
+
This diagram illustrates the end-to-end journey of a request when a user uploads a file and clicks "Analyze".
|
| 412 |
+
|
| 413 |
+
- **Complete Architecture Transformation**: Streamlit → React + FastAPI
|
| 414 |
+
- **Scientific Fidelity Preservation**: Exact ML behavior maintained
|
| 415 |
+
- **Full Provenance Tracking**: QC checks, preprocessing parameters, model metadata
|
| 416 |
+
- **RESTful API**: Programmatic access to all functionality
|
| 417 |
+
- **Single-Container Deployment**: Optimized for Hugging Face Spaces
|
| 418 |
+
- **Type Safety**: Full TypeScript implementation
|
| 419 |
+
- **Professional UI**: Production-ready interface design
|
| 420 |
+
- **Comprehensive Testing**: API validation and frontend testing
|
| 421 |
+
- **Performance Optimization**: Async processing and memory management
|
| 422 |
+
- **Documentation**: Auto-generated API docs and comprehensive README
|
| 423 |
+
|
| 424 |
+
``` mermaid
|
| 425 |
+
sequenceDiagram
|
| 426 |
+
participant User
|
| 427 |
+
participant StandardAnalysis as StandardAnalysis.tsx
|
| 428 |
+
participant apiClient as apiClient.ts
|
| 429 |
+
participant FastAPI as Backend (main.py)
|
| 430 |
+
participant MLService as ML Service (service.py)
|
| 431 |
+
User->>StandardAnalysis: Drops a file into the upload zone
|
| 432 |
+
StandardAnalysis->>apiClient: Calls `uploadSpectrum(file)`
|
| 433 |
+
apiClient->>FastAPI: POST /api/v1/upload with file data
|
| 434 |
+
FastAPI-->>apiClient: Returns parsed `SpectrumData` JSON
|
| 435 |
+
apiClient-->>StandardAnalysis: Returns `SpectrumData` object
|
| 436 |
+
StandardAnalysis->>StandardAnalysis: Sets spectrum state, UI updates to show chart
|
| 437 |
+
User->>StandardAnalysis: Clicks "Analyze Spectrum" button
|
| 438 |
+
StandardAnalysis->>apiClient: Calls `analyzeSpectrum({ spectrum, model_name, ... })`
|
| 439 |
+
apiClient->>FastAPI: POST /api/v1/analyze with `AnalysisRequest` JSON
|
| 440 |
+
Note over FastAPI: Validates request against Pydantic model
|
| 441 |
+
FastAPI->>MLService: Calls `run_inference(spectrum, model_name, ...)`
|
| 442 |
+
Note over MLService: Preprocesses data, loads model, runs prediction
|
| 443 |
+
MLService-->>FastAPI: Returns a complete `PredictionResult` object
|
| 444 |
+
FastAPI-->>apiClient: Returns `PredictionResult` as JSON
|
| 445 |
+
apiClient-->>StandardAnalysis: Returns `PredictionResult` object
|
| 446 |
+
StandardAnalysis->>StandardAnalysis: Sets result state, passing data to `<ResultsDisplay />`
|
| 447 |
+
Note over StandardAnalysis: UI updates to show prediction, confidence, and all metadata.
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
## 9. Developer Workflow Guide
|
| 453 |
+
|
| 454 |
+
This section outlines common development tasks and best practices for this project.
|
| 455 |
+
|
| 456 |
+
### How to Add a New Model
|
| 457 |
+
|
| 458 |
+
1. **Create the Model File**: Add your PyTorch model class to a new file in `backend/models/` (e.g., `my_new_cnn.py`).
|
| 459 |
+
2. **Register the Model**: In `backend/models/registry.py`:
|
| 460 |
+
- Import your new model class.
|
| 461 |
+
- Add a new entry to the `_REGISTRY` dictionary with a unique key and a lambda function to instantiate your model.
|
| 462 |
+
- Add a corresponding entry to the `_MODEL_SPECS` dictionary with its metadata (description, performance, etc.).
|
| 463 |
+
3. **Add Weights**: Place the trained model weights (`.pth` file) in the `backend/model_weights/` directory, ensuring the filename matches the key you used in the registry (e.g., `my_new_cnn_model.pth`).
|
| 464 |
+
4. **Verify**: Restart the application. The new model should automatically appear in the "Select Model" dropdown in the UI.
|
| 465 |
+
|
| 466 |
+
How to Add a New API Endpoint
|
| 467 |
+
|
| 468 |
+
1. **Backend (`models.py`)**: Define the Pydantic models for the request body and response. This enforces the data contract.
|
| 469 |
+
2. **Backend (`service.py`)**: Add the core business logic for the new feature into a new method in the `MLInferenceService`.
|
| 470 |
+
3. **Backend (`main.py`)**: Create a new FastAPI route (e.g., `@app.post("/api/v1/my-new-endpoint")`) that takes the request model, calls the new service method, and returns the response model.
|
| 471 |
+
4. **Update API Schema**: After adding the endpoint, regenerate the `openapi-schema.json` file. You can do this by running the app and copying the JSON from `http://localhost:8000/api/v1/openapi.json`.
|
| 472 |
+
5. **Update Frontend Types**: Run the `openapi-typescript` tool to regenerate `frontend/src/types/api.ts` from the new schema.
|
| 473 |
+
|
| 474 |
+
```bash
|
| 475 |
+
npx openapi-typescript openapi-schema.json --output frontend/src/types/api.ts
|
| 476 |
+
```
|
| 477 |
+
|
| 478 |
+
6. **Frontend (`apiClient.ts`)**: Add a new method to the `ApiClient` class that calls the new endpoint and uses the newly generated TypeScript types.
|
| 479 |
+
7. **Frontend (Component)**: Create or update a React component to call the new `apiClient` method and display the results.
|
| 480 |
+
|
| 481 |
+
```mermaid
|
| 482 |
+
flowchart TD
|
| 483 |
+
subgraph Frontend [Frontend: React + TypeScript]
|
| 484 |
+
UI[UI Components]
|
| 485 |
+
APIClient[apiClient.ts]
|
| 486 |
+
end
|
| 487 |
+
subgraph Backend [Backend: FastAPI + PyTorch]
|
| 488 |
+
Main[main.py (routes + startup)]
|
| 489 |
+
Service[service.py (MLInferenceService)]
|
| 490 |
+
Models[backend/models/registry.py]
|
| 491 |
+
Utils[backend/utils/*]
|
| 492 |
+
end
|
| 493 |
+
subgraph Data [Persistence & Artifacts]
|
| 494 |
+
Weights[Model Weights .pth]
|
| 495 |
+
Logs[Performance Logs]
|
| 496 |
+
end
|
| 497 |
+
UI --> APIClient
|
| 498 |
+
APIClient --> Main
|
| 499 |
+
Main --> Service
|
| 500 |
+
Service --> Models
|
| 501 |
+
Service --> Utils
|
| 502 |
+
Models --> Weights
|
| 503 |
+
Service --> Logs
|
| 504 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- STAGE 1: Frontend Builder ---
|
| 2 |
+
FROM node:18-slim AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package*.json ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
# --- STAGE 2: Backend Runtime ---
|
| 10 |
+
FROM python:3.12-slim
|
| 11 |
+
|
| 12 |
+
# Install ONLY runtime libraries (no compilers/git)
|
| 13 |
+
RUN apt-get update && apt-get install -y \
|
| 14 |
+
curl \
|
| 15 |
+
libopenblas0 \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Set up user
|
| 19 |
+
RUN useradd -m -u 1000 user
|
| 20 |
+
ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
|
| 21 |
+
WORKDIR $HOME/app
|
| 22 |
+
|
| 23 |
+
# We need compilers ONLY for the pip install phase
|
| 24 |
+
# Use a temporary root session to install, then clean up
|
| 25 |
+
COPY --chown=user requirements.txt ./
|
| 26 |
+
RUN apt-get update && apt-get install -y build-essential \
|
| 27 |
+
&& pip install --no-cache-dir --user -r requirements.txt \
|
| 28 |
+
&& apt-get purge -y build-essential && apt-get autoremove -y \
|
| 29 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 30 |
+
|
| 31 |
+
USER user
|
| 32 |
+
|
| 33 |
+
# Copy backend code
|
| 34 |
+
COPY --chown=user backend/ ./backend/
|
| 35 |
+
|
| 36 |
+
# Copy React build from builder stage
|
| 37 |
+
# NOTE: Using 'build' because that is the default React output folder
|
| 38 |
+
COPY --chown=user --from=frontend-builder /app/frontend/build ./frontend/dist
|
| 39 |
+
|
| 40 |
+
# Expose port
|
| 41 |
+
EXPOSE 7860
|
| 42 |
+
|
| 43 |
+
# Start FastAPI
|
| 44 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright 2025 Jaser H.
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Polymer Aging with ML
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# PolymerOS: AI-Driven Polymer Aging Prediction and Classification
|
| 13 |
+
|
| 14 |
+

|
| 15 |
+

|
| 16 |
+

|
| 17 |
+

|
| 18 |
+

|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Overview
|
| 23 |
+
|
| 24 |
+
**PolymerOS** is a full-stack AI application that classifies the degradation state of polymers using **Raman** and **FTIR spectroscopy**.
|
| 25 |
+
It enables scientists, engineers, and researchers to upload spectroscopic data and receive predictions on whether materials are **stable (unweathered)** or **weathered (degraded)**.
|
| 26 |
+
|
| 27 |
+
This platform was designed for both research reproducibility and production-grade deployment. Every prediction includes **scientific provenance tracking**, ensuring transparency in preprocessing, QC checks, and model metadata.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Features
|
| 32 |
+
|
| 33 |
+
- **Multi-Modal Input**: Supports Raman and FTIR data
|
| 34 |
+
- **Flexible File Formats**: Accepts `.txt`, `.csv`, `.json` (auto-detected)
|
| 35 |
+
- **Model Zoo**: Figure2CNN, ResNet1D, ResNet18Vision, and custom CNNs
|
| 36 |
+
- **Batch & Single-Spectrum Analysis**
|
| 37 |
+
- **Multi-Model Comparison**
|
| 38 |
+
- **Provenance Tracking**: QC checks + preprocessing metadata
|
| 39 |
+
- **Performance Dashboard**
|
| 40 |
+
- **RESTful API** for programmatic access
|
| 41 |
+
- **Modern UI**: React + TypeScript, charting with Recharts
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Architecture
|
| 46 |
+
|
| 47 |
+
PolymerOS is a **monorepo** with a React frontend and FastAPI backend, packaged for **single-container deployment**.
|
| 48 |
+
|
| 49 |
+
```text
|
| 50 |
+
polymeros/
|
| 51 |
+
├── backend/
|
| 52 |
+
│ ├── main.py # FastAPI entrypoint: routes, middleware, startup
|
| 53 |
+
│ ├── service.py # Core MLInferenceService logic
|
| 54 |
+
│ ├── pydantic_models.py # API contracts for request/response validation
|
| 55 |
+
│ ├── utils/ # Preprocessing, performance, orchestration
|
| 56 |
+
│ └── models/
|
| 57 |
+
│ ├── registry.py # Model registry for AI models
|
| 58 |
+
│ └── figure2_cnn.py # Example CNN implementation
|
| 59 |
+
├── frontend/
|
| 60 |
+
│ ├── src/
|
| 61 |
+
│ │ ├── App.tsx # Main React app shell
|
| 62 |
+
│ │ ├── apiClient.ts # Centralized API calls
|
| 63 |
+
│ │ ├── components/ # Reusable UI components
|
| 64 |
+
│ │ └── types/api.ts # Auto-generated TypeScript types
|
| 65 |
+
│ └── package.json
|
| 66 |
+
├── models/weights/ # Directory for trained model weights (.pth)
|
| 67 |
+
├── Dockerfile # Multi-stage build for backend+frontend
|
| 68 |
+
└── main.py # Root entrypoint: serves frontend + backend
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
## Data Flow
|
| 74 |
+
|
| 75 |
+
Example: Standard Spectrum Analysis
|
| 76 |
+
|
| 77 |
+
```mermaid
|
| 78 |
+
sequenceDiagram
|
| 79 |
+
participant User
|
| 80 |
+
participant UI as React UI
|
| 81 |
+
participant API as FastAPI Backend
|
| 82 |
+
participant ML as ML Service
|
| 83 |
+
User->>UI: Upload spectrum file
|
| 84 |
+
UI->>API: POST /api/v1/upload
|
| 85 |
+
API->>ML: Parse + preprocess spectrum
|
| 86 |
+
ML->>ML: Run model inference
|
| 87 |
+
ML-->>API: PredictionResult (class + confidence + provenance)
|
| 88 |
+
API-->>UI: JSON response
|
| 89 |
+
UI-->>User: Display prediction & visualization
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Getting Started
|
| 95 |
+
|
| 96 |
+
### Prerequisites
|
| 97 |
+
|
| 98 |
+
- Python 3.10+
|
| 99 |
+
- Node.js 16+
|
| 100 |
+
- npm
|
| 101 |
+
- Git
|
| 102 |
+
|
| 103 |
+
### Setup
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
# Clone repo
|
| 107 |
+
git clone https://github.com/devjas1/polymeros.git
|
| 108 |
+
cd polymeros
|
| 109 |
+
|
| 110 |
+
# Backend setup
|
| 111 |
+
pip install -r backend/requirements.txt
|
| 112 |
+
|
| 113 |
+
# Frontend setup
|
| 114 |
+
cd frontend
|
| 115 |
+
npm install
|
| 116 |
+
cd ..
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### Run (Dev Mode)
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
# Backend (FastAPI with live reload)
|
| 123 |
+
uvicorn backend.main:app --reload --port 8000
|
| 124 |
+
|
| 125 |
+
# Frontend (React)
|
| 126 |
+
cd frontend
|
| 127 |
+
npm start
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### Run (Single-Container)
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
python main.py
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
Access the app at: **http://localhost:8000**
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## Tests
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
# From repo root
|
| 144 |
+
export PYTHONPATH=$PWD
|
| 145 |
+
pytest backend/tests
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Deployment
|
| 151 |
+
|
| 152 |
+
### Docker
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
docker build -t polymeros .
|
| 156 |
+
docker run -p 8000:8000 polymeros
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Hugging Face Spaces
|
| 160 |
+
|
| 161 |
+
PolymerOS is optimized for deployment on Hugging Face Spaces with unified API + UI on a single port.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Contributors
|
| 166 |
+
|
| 167 |
+
- **Jaser Hasan** — Author & Developer
|
| 168 |
+
- **Dr. Sanmukh Kuppannagari** — Mentor
|
| 169 |
+
- **Dr. Metin Karailyan** — Mentor
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
## License
|
| 174 |
+
|
| 175 |
+
Apache 2.0 — see [LICENSE](LICENSE)
|
| 176 |
+
|
| 177 |
+
---
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Backend Package
|
backend/config.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Configuration constants for the backend API
|
| 2 |
+
TARGET_LEN = 500
|
| 3 |
+
LABEL_MAP = {0: "Stable", 1: "Weathered"}
|
| 4 |
+
SAMPLE_DATA_DIR = "sample_data"
|
backend/main.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=unused-argument, unused-import, redefined-outer-name,
|
| 2 |
+
# missing-class-docstring, wrong-import-order, missing-function-docstring
|
| 3 |
+
# missing-module-docstring, broad-except, too-many-locals, too-many-stat
|
| 4 |
+
"""
|
| 5 |
+
FastAPI main application.
|
| 6 |
+
Serves both the API endpoints and React frontend static files.
|
| 7 |
+
Single-container deployment for Hugging Face Spaces compatibility.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import List, Dict, Any, Optional
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import uuid
|
| 15 |
+
import statistics
|
| 16 |
+
import math
|
| 17 |
+
from pydantic import BaseModel, Field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI, HTTPException, File, UploadFile, BackgroundTasks, APIRouter
|
| 21 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 24 |
+
from fastapi.exceptions import RequestValidationError
|
| 25 |
+
from contextlib import asynccontextmanager
|
| 26 |
+
|
| 27 |
+
from backend.service import ml_service, MLServiceError
|
| 28 |
+
from backend.utils.model_manager import model_manager
|
| 29 |
+
from backend.utils.enhanced_ml_service import enhanced_ml_service
|
| 30 |
+
from .pydantic_models import (
|
| 31 |
+
SpectrumData,
|
| 32 |
+
AnalysisRequest,
|
| 33 |
+
BatchAnalysisRequest,
|
| 34 |
+
ComparisonRequest,
|
| 35 |
+
PredictionResult,
|
| 36 |
+
ExplanationResult,
|
| 37 |
+
BatchPredictionResult,
|
| 38 |
+
ComparisonResult,
|
| 39 |
+
ModelInfo,
|
| 40 |
+
SystemInfo,
|
| 41 |
+
SystemHealth,
|
| 42 |
+
ErrorResponse,
|
| 43 |
+
BatchError,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
from backend.utils.prepare_data import prepare_data as run_prepare_data
|
| 47 |
+
from backend.utils.train import train as run_training_job
|
| 48 |
+
from backend.utils.multifile import parse_spectrum_data
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@asynccontextmanager
|
| 52 |
+
async def lifespan(app: FastAPI):
|
| 53 |
+
"""Application lifespan manager"""
|
| 54 |
+
# Startup
|
| 55 |
+
print("🚀 Starting Polymer Aging ML API...")
|
| 56 |
+
|
| 57 |
+
# Warmup models (load them into cache)
|
| 58 |
+
# Use the centralized model_manager for loading
|
| 59 |
+
try:
|
| 60 |
+
print("Pre-loading models via ModelManager...")
|
| 61 |
+
available_models_info = model_manager.get_available_models()
|
| 62 |
+
print(f"✅ Discovered {len(available_models_info)} models.")
|
| 63 |
+
|
| 64 |
+
# Warmup with a dummy spectrum if models are available
|
| 65 |
+
loaded_models_count = 0
|
| 66 |
+
for model_info in available_models_info:
|
| 67 |
+
if model_info.available:
|
| 68 |
+
ml_service.model_manager.load_model(
|
| 69 |
+
model_info.name
|
| 70 |
+
) # Ensure models are loaded into ml_service's manager
|
| 71 |
+
loaded_models_count += 1
|
| 72 |
+
print(f"✅ {loaded_models_count} models loaded into ModelManager.")
|
| 73 |
+
|
| 74 |
+
if loaded_models_count > 0:
|
| 75 |
+
dummy_spectrum = SpectrumData(
|
| 76 |
+
x_values=list(range(200, 4000, 10)),
|
| 77 |
+
y_values=[0.5] * len(list(range(200, 4000, 10))),
|
| 78 |
+
filename="warmup",
|
| 79 |
+
)
|
| 80 |
+
print("✅ Models warmed up successfully")
|
| 81 |
+
except (
|
| 82 |
+
KeyError,
|
| 83 |
+
ValueError,
|
| 84 |
+
RuntimeError,
|
| 85 |
+
) as e: # Replace with specific exceptions
|
| 86 |
+
print(f"⚠️ Model warmup failed: {e}")
|
| 87 |
+
|
| 88 |
+
yield
|
| 89 |
+
|
| 90 |
+
# Shutdown
|
| 91 |
+
print("🔄 Shutting down Polymer Aging ML API...")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --- In-memory DB for Training Jobs ---
|
| 95 |
+
training_jobs: Dict[str, Dict[str, Any]] = {}
|
| 96 |
+
|
| 97 |
+
# --- Pydantic Models Building Blocks for Training API ---
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class PrepareDataRequest(BaseModel):
|
| 101 |
+
raw_data_path: str = Field(
|
| 102 |
+
..., description="Path to the raw dataset (e.g., a single CSV or a directory)."
|
| 103 |
+
)
|
| 104 |
+
output_path: str = Field(
|
| 105 |
+
default="data/processed",
|
| 106 |
+
description="Directory to save the processed train/val/test splits.",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class TrainingJobConfig(BaseModel):
|
| 111 |
+
experiment_name: str = "PolymerAgingClassification"
|
| 112 |
+
run_name: str = Field(
|
| 113 |
+
default_factory=lambda: f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 114 |
+
)
|
| 115 |
+
data_dir: str = "data/processed"
|
| 116 |
+
train_csv: str = "train.csv"
|
| 117 |
+
val_csv: str = "validation.csv"
|
| 118 |
+
model_name: str
|
| 119 |
+
epochs: int = 50
|
| 120 |
+
batch_size: int = 32
|
| 121 |
+
learning_rate: float = 0.001
|
| 122 |
+
optimizer: str = "Adam"
|
| 123 |
+
loss_function: str = "CrossEntropyLoss"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class TrainingJobStatus(BaseModel):
|
| 127 |
+
job_id: str
|
| 128 |
+
status: str # PENDING, RUNNING, COMPLETED, FAILED
|
| 129 |
+
config: TrainingJobConfig
|
| 130 |
+
progress: float = 0.0
|
| 131 |
+
current_epoch: int = 0
|
| 132 |
+
mlflow_run_id: Optional[str] = None
|
| 133 |
+
metrics: Dict[str, list] = Field(
|
| 134 |
+
default_factory=lambda: {"train_loss": [], "val_loss": []}
|
| 135 |
+
)
|
| 136 |
+
error: Optional[str] = None
|
| 137 |
+
created_at: str
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
app = FastAPI(
|
| 141 |
+
title="Polymer Aging ML API",
|
| 142 |
+
description="AI-driven polymer aging prediction and classification using Raman and FTIR spectroscopy",
|
| 143 |
+
version="1.0.0",
|
| 144 |
+
docs_url="/api/v1/docs",
|
| 145 |
+
redoc_url="/api/v1/redoc",
|
| 146 |
+
openapi_url="/api/v1/openapi.json",
|
| 147 |
+
lifespan=lifespan,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# --- Hardened CORS Configuration ---
|
| 151 |
+
# Only allow specific origins. In production (HF), the frontend is same-origin.
|
| 152 |
+
_allowed = os.getenv(
|
| 153 |
+
"CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:7860"
|
| 154 |
+
)
|
| 155 |
+
origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
| 156 |
+
|
| 157 |
+
app.add_middleware(
|
| 158 |
+
CORSMiddleware,
|
| 159 |
+
allow_origins=origins,
|
| 160 |
+
allow_credentials=True,
|
| 161 |
+
allow_methods=["GET", "POST", "OPTIONS"], # Limit to only what the app needs
|
| 162 |
+
allow_headers=["*"],
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# Error handlers
|
| 167 |
+
@app.exception_handler(RequestValidationError)
|
| 168 |
+
async def validation_exception_handler(request, exc):
|
| 169 |
+
# Sanitize validation errors to ensure they are JSON serializable
|
| 170 |
+
cleaned_errors = []
|
| 171 |
+
for error in exc.errors():
|
| 172 |
+
cleaned_error = error.copy()
|
| 173 |
+
if "ctx" in cleaned_error and isinstance(cleaned_error["ctx"], dict):
|
| 174 |
+
# The context can contain non-serializable objects like exceptions.
|
| 175 |
+
# We'll convert all context values to strings for a safe response.
|
| 176 |
+
cleaned_error["ctx"] = {k: str(v) for k, v in cleaned_error["ctx"].items()}
|
| 177 |
+
cleaned_errors.append(cleaned_error)
|
| 178 |
+
return JSONResponse(
|
| 179 |
+
status_code=422,
|
| 180 |
+
content=ErrorResponse(
|
| 181 |
+
error="Validation Error",
|
| 182 |
+
error_code="VALIDATION_ERROR",
|
| 183 |
+
details={"validation_errors": cleaned_errors},
|
| 184 |
+
timestamp=datetime.now().isoformat(),
|
| 185 |
+
request_id=str(uuid.uuid4()),
|
| 186 |
+
).dict(),
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.exception_handler(Exception)
|
| 191 |
+
async def general_exception_handler(request, exc):
|
| 192 |
+
return JSONResponse(
|
| 193 |
+
status_code=500,
|
| 194 |
+
content=ErrorResponse(
|
| 195 |
+
error=str(exc) if str(exc) else "Internal server error",
|
| 196 |
+
error_code="INTERNAL_ERROR",
|
| 197 |
+
details={}, # Provide an empty dictionary as the default value
|
| 198 |
+
timestamp=datetime.now().isoformat(),
|
| 199 |
+
request_id=str(uuid.uuid4()),
|
| 200 |
+
).dict(),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# --- API Router for Training ---
|
| 205 |
+
training_router = APIRouter(prefix="/api/v1/training", tags=["Training"])
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@training_router.post("/prepare-data", summary="Prepare and Split Dataset")
|
| 209 |
+
def prepare_data_endpoint(request: PrepareDataRequest):
|
| 210 |
+
"""
|
| 211 |
+
Triggers the data preparation script to create train/validation/test splits.
|
| 212 |
+
In a real web app, this would handle an uploaded zip file.
|
| 213 |
+
"""
|
| 214 |
+
try:
|
| 215 |
+
raw_path = Path(request.raw_data_path)
|
| 216 |
+
output_path = Path(request.output_path)
|
| 217 |
+
run_prepare_data(data_path=raw_path, output_path=output_path)
|
| 218 |
+
return {"message": f"Data preparation complete. Splits saved to {output_path}."}
|
| 219 |
+
except Exception as e:
|
| 220 |
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@training_router.post(
|
| 224 |
+
"/start",
|
| 225 |
+
response_model=TrainingJobStatus,
|
| 226 |
+
status_code=202,
|
| 227 |
+
summary="Start a New Training Job",
|
| 228 |
+
)
|
| 229 |
+
def start_training(config: TrainingJobConfig, background_tasks: BackgroundTasks):
|
| 230 |
+
"""
|
| 231 |
+
Starts a new model training job in the background.
|
| 232 |
+
"""
|
| 233 |
+
job_id = str(uuid.uuid4())
|
| 234 |
+
job_status = TrainingJobStatus(
|
| 235 |
+
job_id=job_id,
|
| 236 |
+
status="PENDING",
|
| 237 |
+
config=config,
|
| 238 |
+
created_at=datetime.now().isoformat(),
|
| 239 |
+
).dict()
|
| 240 |
+
training_jobs[job_id] = job_status
|
| 241 |
+
|
| 242 |
+
# Add the long-running training task to the background
|
| 243 |
+
background_tasks.add_task(
|
| 244 |
+
run_training_job, config=config.dict(), jobs_db=training_jobs, job_id=job_id
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
return job_status
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@training_router.get(
|
| 251 |
+
"/jobs", response_model=List[TrainingJobStatus], summary="List All Training Jobs"
|
| 252 |
+
)
|
| 253 |
+
def list_training_jobs():
|
| 254 |
+
"""Retrieves the status of all training jobs."""
|
| 255 |
+
return list(training_jobs.values())
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@training_router.get(
|
| 259 |
+
"/jobs/{job_id}",
|
| 260 |
+
response_model=TrainingJobStatus,
|
| 261 |
+
summary="Get Training Job Status",
|
| 262 |
+
)
|
| 263 |
+
def get_training_job_status(job_id: str):
|
| 264 |
+
"""Retrieves the status of a specific training job by its ID."""
|
| 265 |
+
if job_id not in training_jobs:
|
| 266 |
+
raise HTTPException(status_code=404, detail="Training job not found")
|
| 267 |
+
return training_jobs[job_id]
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# API Routes
|
| 271 |
+
@app.get("/api/v1/health")
|
| 272 |
+
async def health_check():
|
| 273 |
+
"""Health check endpoint"""
|
| 274 |
+
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@app.get("/api/v1/system", response_model=SystemInfo)
|
| 278 |
+
async def get_system_info():
|
| 279 |
+
"""Get system information and available models"""
|
| 280 |
+
return ml_service.get_system_info()
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@app.get("/api/v1/models", response_model=List[ModelInfo])
|
| 284 |
+
async def get_models():
|
| 285 |
+
"""Get list of available models"""
|
| 286 |
+
print("🔍 Fetching available models...")
|
| 287 |
+
# Directly use the centralized model manager
|
| 288 |
+
models = model_manager.get_available_models()
|
| 289 |
+
if not models:
|
| 290 |
+
print(
|
| 291 |
+
"⚠️ No models found via ModelManager. Falling back to filesystem scan (this should ideally not be needed)."
|
| 292 |
+
)
|
| 293 |
+
# This fallback is now less critical as ModelManager should handle discovery
|
| 294 |
+
# but keeping it for extreme resilience as per original request.
|
| 295 |
+
# The ModelManager itself already checks for weight file existence.
|
| 296 |
+
return models
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@app.post("/api/v1/analyze", response_model=PredictionResult)
|
| 300 |
+
async def analyze_spectrum(request: AnalysisRequest):
|
| 301 |
+
"""Analyze a single spectrum"""
|
| 302 |
+
try:
|
| 303 |
+
result = ml_service.run_inference(
|
| 304 |
+
request.spectrum,
|
| 305 |
+
request.model_name,
|
| 306 |
+
request.modality,
|
| 307 |
+
request.include_provenance,
|
| 308 |
+
)
|
| 309 |
+
return result
|
| 310 |
+
except MLServiceError as e:
|
| 311 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ** fix-429e36db-a89a-42f9-8b64-9bdfd16b01bc
|
| 315 |
+
@app.post("/api/v1/explain")
|
| 316 |
+
async def explain_spectrum(request: AnalysisRequest):
|
| 317 |
+
"""Analyze a spectrum with explainability features"""
|
| 318 |
+
try:
|
| 319 |
+
# Ensure we pass modality and use the same include_provenance flag
|
| 320 |
+
result = enhanced_ml_service.predict_with_explanation(
|
| 321 |
+
request.spectrum, # SpectrumData
|
| 322 |
+
request.model_name, # model name
|
| 323 |
+
modality=request.modality, # pass modality (raman/ftir)
|
| 324 |
+
include_feature_importance=request.include_provenance,
|
| 325 |
+
)
|
| 326 |
+
return result
|
| 327 |
+
except Exception as e:
|
| 328 |
+
# Log full traceback for debugging
|
| 329 |
+
import traceback, sys
|
| 330 |
+
|
| 331 |
+
print(
|
| 332 |
+
"[explain] Error during prediction with explanation:",
|
| 333 |
+
str(e),
|
| 334 |
+
file=sys.stderr,
|
| 335 |
+
)
|
| 336 |
+
traceback.print_exc()
|
| 337 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@app.post("/api/v1/explain/batch")
|
| 341 |
+
async def explain_batch_spectra(request: BatchAnalysisRequest):
|
| 342 |
+
"""Analyze multiple spectra with explainability features"""
|
| 343 |
+
if len(request.spectra) > 50: # Lower limit for explanation requests
|
| 344 |
+
raise HTTPException(
|
| 345 |
+
status_code=400,
|
| 346 |
+
detail="Batch explainability requests limited to 50 spectra",
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
try:
|
| 350 |
+
results = enhanced_ml_service.batch_predict_with_explanation(
|
| 351 |
+
request.spectra,
|
| 352 |
+
request.model_name,
|
| 353 |
+
modality=request.modality, # Pass modality to the enhanced service
|
| 354 |
+
include_feature_importance=request.include_provenance, # Use include_provenance for feature importance
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
return {
|
| 358 |
+
"results": results,
|
| 359 |
+
"total_processed": len(results),
|
| 360 |
+
"model_used": request.model_name,
|
| 361 |
+
"timestamp": datetime.now().isoformat(),
|
| 362 |
+
}
|
| 363 |
+
except Exception as e:
|
| 364 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ** fix-429e36db-a89a-42f9-8b64-9bdfd16b01bc
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@app.post("/api/v1/analyze/batch", response_model=BatchPredictionResult)
|
| 371 |
+
async def analyze_batch(request: BatchAnalysisRequest):
|
| 372 |
+
"""Analyze multiple spectra in batch"""
|
| 373 |
+
if len(request.spectra) > 100:
|
| 374 |
+
raise HTTPException(
|
| 375 |
+
status_code=400, detail="Batch size cannot exceed 100 spectra"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
start_time = datetime.now()
|
| 379 |
+
results = []
|
| 380 |
+
errors = []
|
| 381 |
+
|
| 382 |
+
for spectrum in request.spectra:
|
| 383 |
+
try:
|
| 384 |
+
result = ml_service.run_inference(
|
| 385 |
+
spectrum,
|
| 386 |
+
request.model_name,
|
| 387 |
+
request.modality,
|
| 388 |
+
request.include_provenance,
|
| 389 |
+
)
|
| 390 |
+
results.append(result)
|
| 391 |
+
except (ValueError, KeyError, RuntimeError) as e:
|
| 392 |
+
errors.append(BatchError(filename=spectrum.filename, error=str(e)))
|
| 393 |
+
|
| 394 |
+
total_time = (datetime.now() - start_time).total_seconds()
|
| 395 |
+
|
| 396 |
+
# Initialize summary statistics with default values
|
| 397 |
+
average_confidence = 0.0
|
| 398 |
+
confidence_std = 0.0
|
| 399 |
+
min_confidence = 0.0
|
| 400 |
+
max_confidence = 0.0
|
| 401 |
+
predictions = []
|
| 402 |
+
|
| 403 |
+
# Calculate summary statistics only on successful results
|
| 404 |
+
if results:
|
| 405 |
+
# Calculate summary statistics
|
| 406 |
+
confidences = [r.confidence for r in results]
|
| 407 |
+
predictions = [r.prediction for r in results]
|
| 408 |
+
|
| 409 |
+
if confidences:
|
| 410 |
+
average_confidence = statistics.mean(confidences)
|
| 411 |
+
confidence_std = (
|
| 412 |
+
statistics.stdev(confidences) if len(confidences) > 1 else 0.0
|
| 413 |
+
)
|
| 414 |
+
min_confidence = min(confidences)
|
| 415 |
+
max_confidence = max(confidences)
|
| 416 |
+
|
| 417 |
+
summary = {
|
| 418 |
+
"total_spectra_requested": len(request.spectra),
|
| 419 |
+
"total_spectra_processed": len(results),
|
| 420 |
+
"total_spectra_failed": len(errors),
|
| 421 |
+
"stable_count": sum(1 for p in predictions if p == 0) if results else 0,
|
| 422 |
+
"weathered_count": sum(1 for p in predictions if p == 1) if results else 0,
|
| 423 |
+
"average_confidence": average_confidence if results else 0.0,
|
| 424 |
+
"confidence_std": confidence_std if results else 0.0,
|
| 425 |
+
"min_confidence": min_confidence if results else 0.0,
|
| 426 |
+
"max_confidence": max_confidence if results else 0.0,
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
return BatchPredictionResult(
|
| 430 |
+
results=results,
|
| 431 |
+
errors=errors,
|
| 432 |
+
summary=summary,
|
| 433 |
+
total_processing_time=total_time,
|
| 434 |
+
timestamp=datetime.now().isoformat(),
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
@app.post("/api/v1/compare", response_model=ComparisonResult)
|
| 439 |
+
async def compare_models(request: ComparisonRequest):
|
| 440 |
+
"""Compare multiple models on a single spectrum"""
|
| 441 |
+
try:
|
| 442 |
+
available_models = ml_service.get_available_models()
|
| 443 |
+
|
| 444 |
+
if request.model_names:
|
| 445 |
+
models_to_test = [
|
| 446 |
+
m.name
|
| 447 |
+
for m in available_models
|
| 448 |
+
if m.name in request.model_names and m.available
|
| 449 |
+
]
|
| 450 |
+
else:
|
| 451 |
+
models_to_test = [m.name for m in available_models if m.available]
|
| 452 |
+
|
| 453 |
+
if not models_to_test:
|
| 454 |
+
raise HTTPException(status_code=400, detail="No available models found")
|
| 455 |
+
|
| 456 |
+
spectrum_id = str(uuid.uuid4())
|
| 457 |
+
model_results = {}
|
| 458 |
+
confidences = []
|
| 459 |
+
predictions = []
|
| 460 |
+
|
| 461 |
+
for model_name in models_to_test:
|
| 462 |
+
result = ml_service.run_inference(
|
| 463 |
+
request.spectrum,
|
| 464 |
+
model_name,
|
| 465 |
+
request.modality,
|
| 466 |
+
request.include_provenance,
|
| 467 |
+
)
|
| 468 |
+
model_results[model_name] = result
|
| 469 |
+
confidences.append(result.confidence)
|
| 470 |
+
predictions.append(result.prediction)
|
| 471 |
+
|
| 472 |
+
# Calculate consensus and agreement
|
| 473 |
+
if predictions:
|
| 474 |
+
# Simple majority vote for consensus
|
| 475 |
+
prediction_counts = {0: predictions.count(0), 1: predictions.count(1)}
|
| 476 |
+
consensus = max(prediction_counts, key=prediction_counts.get)
|
| 477 |
+
|
| 478 |
+
# Agreement score: percentage of models that agree with consensus
|
| 479 |
+
agreement_score = prediction_counts[consensus] / len(predictions)
|
| 480 |
+
|
| 481 |
+
# Confidence variance
|
| 482 |
+
if len(confidences) > 1:
|
| 483 |
+
confidence_variance = statistics.variance(confidences)
|
| 484 |
+
else:
|
| 485 |
+
confidence_variance = 0.0
|
| 486 |
+
else:
|
| 487 |
+
consensus = None
|
| 488 |
+
agreement_score = 0.0
|
| 489 |
+
confidence_variance = 0.0
|
| 490 |
+
|
| 491 |
+
return ComparisonResult(
|
| 492 |
+
spectrum_id=spectrum_id,
|
| 493 |
+
model_results=model_results,
|
| 494 |
+
consensus_prediction=consensus,
|
| 495 |
+
confidence_variance=confidence_variance,
|
| 496 |
+
agreement_score=agreement_score,
|
| 497 |
+
timestamp=datetime.now().isoformat(),
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
except (MLServiceError, KeyError, ValueError, RuntimeError) as e:
|
| 501 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@app.post("/api/v1/upload", response_model=SpectrumData)
|
| 505 |
+
async def upload_spectrum_file(file: UploadFile = File(...)):
|
| 506 |
+
"""Upload and parse a spectrum file"""
|
| 507 |
+
try:
|
| 508 |
+
# Read file content
|
| 509 |
+
content = await file.read()
|
| 510 |
+
|
| 511 |
+
# Parse spectrum data using existing utility
|
| 512 |
+
x_data, y_data = parse_spectrum_data(
|
| 513 |
+
content.decode("utf-8"), file.filename or "unknown_filename"
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
return SpectrumData(
|
| 517 |
+
x_values=x_data.tolist(), y_values=y_data.tolist(), filename=file.filename
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
except Exception as e:
|
| 521 |
+
raise HTTPException(
|
| 522 |
+
status_code=400, detail=f"Failed to parse spectrum file: {str(e)}"
|
| 523 |
+
) from e
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# Backward compatibility routes (redirect to v1)
|
| 527 |
+
@app.get("/api/health")
|
| 528 |
+
async def health_check_legacy():
|
| 529 |
+
"""Legacy health check endpoint - redirects to v1"""
|
| 530 |
+
return await health_check()
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
@app.get("/api/system")
|
| 534 |
+
async def get_system_info_legacy():
|
| 535 |
+
"""Legacy system info endpoint - redirects to v1"""
|
| 536 |
+
return await get_system_info()
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@app.get("/api/models")
|
| 540 |
+
async def get_models_legacy():
|
| 541 |
+
"""Legacy models endpoint - redirects to v1"""
|
| 542 |
+
return await get_models()
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
# Static file serving for React frontend
|
| 546 |
+
# frontend_dist_path = Path("frontend/dist")
|
| 547 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 548 |
+
frontend_dist_path = BASE_DIR / "frontend" / "dist"
|
| 549 |
+
if frontend_dist_path.exists() and frontend_dist_path.is_dir():
|
| 550 |
+
# Mount static files for built React app
|
| 551 |
+
app.mount("/static", StaticFiles(directory="frontend/dist/static"), name="static")
|
| 552 |
+
|
| 553 |
+
@app.get("/")
|
| 554 |
+
async def serve_frontend():
|
| 555 |
+
"""Serve React frontend"""
|
| 556 |
+
index_path = frontend_dist_path / "index.html"
|
| 557 |
+
if index_path.exists():
|
| 558 |
+
return FileResponse(index_path)
|
| 559 |
+
return JSONResponse(
|
| 560 |
+
content={"error": "Frontend index.html not found"}, status_code=404
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
@app.get("/{path:path}")
|
| 564 |
+
async def serve_frontend_routes(path: str):
|
| 565 |
+
"""Serve React frontend for all non-API routes (SPA routing)"""
|
| 566 |
+
if path.startswith("api/"):
|
| 567 |
+
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 568 |
+
|
| 569 |
+
file_path = frontend_dist_path / path
|
| 570 |
+
if file_path.exists() and file_path.is_file():
|
| 571 |
+
return FileResponse(file_path)
|
| 572 |
+
else:
|
| 573 |
+
# For SPA routing, return index.html if it exists
|
| 574 |
+
index_path = frontend_dist_path / "index.html"
|
| 575 |
+
if index_path.exists():
|
| 576 |
+
return FileResponse(index_path)
|
| 577 |
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
| 578 |
+
|
| 579 |
+
else:
|
| 580 |
+
|
| 581 |
+
@app.get("/")
|
| 582 |
+
async def root():
|
| 583 |
+
"""Root endpoint when frontend is not built"""
|
| 584 |
+
return {
|
| 585 |
+
"message": "Polymer Aging ML API",
|
| 586 |
+
"status": "Frontend not built. Build React frontend and place in frontend/dist/",
|
| 587 |
+
"api_docs": "/api/docs",
|
| 588 |
+
"version": "1.0.0",
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
# Include the new training router in the main application
|
| 593 |
+
app.include_router(training_router)
|
| 594 |
+
|
| 595 |
+
if __name__ == "__main__":
|
| 596 |
+
import uvicorn
|
| 597 |
+
|
| 598 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
backend/models/__init__.py
ADDED
|
File without changes
|
backend/models/enhanced_cnn.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
All neural network blocks and architectures in models/enhanced_cnn.py are custom implementations, developed to expand the model registry for advanced polymer spectral classification. While inspired by established deep learning concepts (such as residual connections, attention mechanisms, and multi-scale convolutions), they are are unique to this project and tailored for 1D spectral data.
|
| 3 |
+
|
| 4 |
+
Registry expansion: The purpose is to enrich the available models.
|
| 5 |
+
Literature inspiration: SE-Net, ResNet, Inception.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AttentionBlock1D(nn.Module):
|
| 13 |
+
"""1D attention mechanism for spectral data."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, channels: int, reduction: int = 8):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.channels = channels
|
| 18 |
+
self.global_pool = nn.AdaptiveAvgPool1d(1)
|
| 19 |
+
self.fc = nn.Sequential(
|
| 20 |
+
nn.Linear(channels, channels // reduction),
|
| 21 |
+
nn.ReLU(inplace=True),
|
| 22 |
+
nn.Linear(channels // reduction, channels),
|
| 23 |
+
nn.Sigmoid(),
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
def forward(self, x):
|
| 27 |
+
# x shape: [batch, channels, length]
|
| 28 |
+
b, c, _ = x.size()
|
| 29 |
+
|
| 30 |
+
# Global average pooling
|
| 31 |
+
y = self.global_pool(x).view(b, c)
|
| 32 |
+
|
| 33 |
+
# Fully connected layers
|
| 34 |
+
y = self.fc(y).view(b, c, 1)
|
| 35 |
+
|
| 36 |
+
# Apply attention weights
|
| 37 |
+
return x * y.expand_as(x)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class EnhancedResidualBlock1D(nn.Module):
|
| 41 |
+
"""Enhanced residual block with attention and improved normalization."""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
in_channels: int,
|
| 46 |
+
out_channels: int,
|
| 47 |
+
kernel_size: int = 3,
|
| 48 |
+
use_attention: bool = True,
|
| 49 |
+
dropout_rate: float = 0.1,
|
| 50 |
+
):
|
| 51 |
+
super().__init__()
|
| 52 |
+
padding = kernel_size // 2
|
| 53 |
+
|
| 54 |
+
self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, padding=padding)
|
| 55 |
+
self.bn1 = nn.BatchNorm1d(out_channels)
|
| 56 |
+
self.relu = nn.ReLU(inplace=True)
|
| 57 |
+
|
| 58 |
+
self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size, padding=padding)
|
| 59 |
+
self.bn2 = nn.BatchNorm1d(out_channels)
|
| 60 |
+
|
| 61 |
+
self.dropout = nn.Dropout1d(dropout_rate) if dropout_rate > 0 else nn.Identity()
|
| 62 |
+
|
| 63 |
+
# Skip connection
|
| 64 |
+
self.skip = (
|
| 65 |
+
nn.Identity()
|
| 66 |
+
if in_channels == out_channels
|
| 67 |
+
else nn.Sequential(
|
| 68 |
+
nn.Conv1d(in_channels, out_channels, kernel_size=1),
|
| 69 |
+
nn.BatchNorm1d(out_channels),
|
| 70 |
+
)
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Attention mechanism
|
| 74 |
+
self.attention = (
|
| 75 |
+
AttentionBlock1D(out_channels) if use_attention else nn.Identity()
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def forward(self, x):
|
| 79 |
+
identity = self.skip(x)
|
| 80 |
+
|
| 81 |
+
out = self.conv1(x)
|
| 82 |
+
out = self.bn1(out)
|
| 83 |
+
out = self.relu(out)
|
| 84 |
+
out = self.dropout(out)
|
| 85 |
+
|
| 86 |
+
out = self.conv2(out)
|
| 87 |
+
out = self.bn2(out)
|
| 88 |
+
|
| 89 |
+
# Apply attention
|
| 90 |
+
out = self.attention(out)
|
| 91 |
+
|
| 92 |
+
out = out + identity
|
| 93 |
+
return self.relu(out)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class MultiScaleConvBlock(nn.Module):
|
| 97 |
+
"""Multi-scale convolution block for capturing features at different scales."""
|
| 98 |
+
|
| 99 |
+
def __init__(self, in_channels: int, out_channels: int):
|
| 100 |
+
super().__init__()
|
| 101 |
+
|
| 102 |
+
# Different kernel sizes for multi-scale feature extraction
|
| 103 |
+
self.conv1 = nn.Conv1d(in_channels, out_channels // 4, kernel_size=3, padding=1)
|
| 104 |
+
self.conv2 = nn.Conv1d(in_channels, out_channels // 4, kernel_size=5, padding=2)
|
| 105 |
+
self.conv3 = nn.Conv1d(in_channels, out_channels // 4, kernel_size=7, padding=3)
|
| 106 |
+
self.conv4 = nn.Conv1d(in_channels, out_channels // 4, kernel_size=9, padding=4)
|
| 107 |
+
|
| 108 |
+
self.bn = nn.BatchNorm1d(out_channels)
|
| 109 |
+
self.relu = nn.ReLU(inplace=True)
|
| 110 |
+
|
| 111 |
+
def forward(self, x):
|
| 112 |
+
# Parallel convolutions with different kernel sizes
|
| 113 |
+
out1 = self.conv1(x)
|
| 114 |
+
out2 = self.conv2(x)
|
| 115 |
+
out3 = self.conv3(x)
|
| 116 |
+
out4 = self.conv4(x)
|
| 117 |
+
|
| 118 |
+
# Concatenate along channel dimension
|
| 119 |
+
out = torch.cat([out1, out2, out3, out4], dim=1)
|
| 120 |
+
out = self.bn(out)
|
| 121 |
+
return self.relu(out)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class EnhancedCNN(nn.Module):
|
| 125 |
+
"""Enhanced CNN with attention, multi-scale features, and improved architecture."""
|
| 126 |
+
|
| 127 |
+
def __init__(
|
| 128 |
+
self,
|
| 129 |
+
input_length: int = 500,
|
| 130 |
+
num_classes: int = 2,
|
| 131 |
+
dropout_rate: float = 0.2,
|
| 132 |
+
use_attention: bool = True,
|
| 133 |
+
):
|
| 134 |
+
super().__init__()
|
| 135 |
+
|
| 136 |
+
self.input_length = input_length
|
| 137 |
+
self.num_classes = num_classes
|
| 138 |
+
|
| 139 |
+
# Initial feature extraction
|
| 140 |
+
self.initial_conv = nn.Sequential(
|
| 141 |
+
nn.Conv1d(1, 32, kernel_size=7, padding=3),
|
| 142 |
+
nn.BatchNorm1d(32),
|
| 143 |
+
nn.ReLU(inplace=True),
|
| 144 |
+
nn.MaxPool1d(kernel_size=2),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Multi-scale feature extraction
|
| 148 |
+
self.multiscale_block = MultiScaleConvBlock(32, 64)
|
| 149 |
+
self.pool1 = nn.MaxPool1d(kernel_size=2)
|
| 150 |
+
|
| 151 |
+
# Enhanced residual blocks
|
| 152 |
+
self.res_block1 = EnhancedResidualBlock1D(64, 96, use_attention=use_attention)
|
| 153 |
+
self.pool2 = nn.MaxPool1d(kernel_size=2)
|
| 154 |
+
|
| 155 |
+
self.res_block2 = EnhancedResidualBlock1D(96, 128, use_attention=use_attention)
|
| 156 |
+
self.pool3 = nn.MaxPool1d(kernel_size=2)
|
| 157 |
+
|
| 158 |
+
self.res_block3 = EnhancedResidualBlock1D(128, 160, use_attention=use_attention)
|
| 159 |
+
|
| 160 |
+
# Global feature extraction
|
| 161 |
+
self.global_pool = nn.AdaptiveAvgPool1d(1)
|
| 162 |
+
|
| 163 |
+
# Calculate feature size after convolutions
|
| 164 |
+
self.feature_size = 160
|
| 165 |
+
|
| 166 |
+
# Enhanced classifier with dropout
|
| 167 |
+
self.classifier = nn.Sequential(
|
| 168 |
+
nn.Linear(self.feature_size, 256),
|
| 169 |
+
nn.BatchNorm1d(256),
|
| 170 |
+
nn.ReLU(inplace=True),
|
| 171 |
+
nn.Dropout(dropout_rate),
|
| 172 |
+
nn.Linear(256, 128),
|
| 173 |
+
nn.BatchNorm1d(128),
|
| 174 |
+
nn.ReLU(inplace=True),
|
| 175 |
+
nn.Dropout(dropout_rate),
|
| 176 |
+
nn.Linear(128, 64),
|
| 177 |
+
nn.BatchNorm1d(64),
|
| 178 |
+
nn.ReLU(inplace=True),
|
| 179 |
+
nn.Dropout(dropout_rate / 2),
|
| 180 |
+
nn.Linear(64, num_classes),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Initialize weights
|
| 184 |
+
self._initialize_weights()
|
| 185 |
+
|
| 186 |
+
def _initialize_weights(self):
|
| 187 |
+
"""Initialize model weights using Xavier initialization."""
|
| 188 |
+
for m in self.modules():
|
| 189 |
+
if isinstance(m, nn.Conv1d):
|
| 190 |
+
nn.init.xavier_uniform_(m.weight)
|
| 191 |
+
if m.bias is not None:
|
| 192 |
+
nn.init.constant_(m.bias, 0)
|
| 193 |
+
elif isinstance(m, nn.Linear):
|
| 194 |
+
nn.init.xavier_uniform_(m.weight)
|
| 195 |
+
nn.init.constant_(m.bias, 0)
|
| 196 |
+
elif isinstance(m, nn.BatchNorm1d):
|
| 197 |
+
nn.init.constant_(m.weight, 1)
|
| 198 |
+
nn.init.constant_(m.bias, 0)
|
| 199 |
+
|
| 200 |
+
def forward(self, x):
|
| 201 |
+
# Ensure input is 3D: [batch, channels, length]
|
| 202 |
+
if x.dim() == 2:
|
| 203 |
+
x = x.unsqueeze(1)
|
| 204 |
+
|
| 205 |
+
# Feature extraction
|
| 206 |
+
x = self.initial_conv(x)
|
| 207 |
+
x = self.multiscale_block(x)
|
| 208 |
+
x = self.pool1(x)
|
| 209 |
+
|
| 210 |
+
x = self.res_block1(x)
|
| 211 |
+
x = self.pool2(x)
|
| 212 |
+
|
| 213 |
+
x = self.res_block2(x)
|
| 214 |
+
x = self.pool3(x)
|
| 215 |
+
|
| 216 |
+
x = self.res_block3(x)
|
| 217 |
+
|
| 218 |
+
# Global pooling
|
| 219 |
+
x = self.global_pool(x)
|
| 220 |
+
x = x.view(x.size(0), -1)
|
| 221 |
+
|
| 222 |
+
# Classification
|
| 223 |
+
x = self.classifier(x)
|
| 224 |
+
|
| 225 |
+
return x
|
| 226 |
+
|
| 227 |
+
def get_feature_maps(self, x):
|
| 228 |
+
"""Extract intermediate feature maps for visualization."""
|
| 229 |
+
if x.dim() == 2:
|
| 230 |
+
x = x.unsqueeze(1)
|
| 231 |
+
|
| 232 |
+
features = {}
|
| 233 |
+
|
| 234 |
+
x = self.initial_conv(x)
|
| 235 |
+
features["initial"] = x
|
| 236 |
+
|
| 237 |
+
x = self.multiscale_block(x)
|
| 238 |
+
features["multiscale"] = x
|
| 239 |
+
x = self.pool1(x)
|
| 240 |
+
|
| 241 |
+
x = self.res_block1(x)
|
| 242 |
+
features["res1"] = x
|
| 243 |
+
x = self.pool2(x)
|
| 244 |
+
|
| 245 |
+
x = self.res_block2(x)
|
| 246 |
+
features["res2"] = x
|
| 247 |
+
x = self.pool3(x)
|
| 248 |
+
|
| 249 |
+
x = self.res_block3(x)
|
| 250 |
+
features["res3"] = x
|
| 251 |
+
|
| 252 |
+
return features
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class EfficientSpectralCNN(nn.Module):
|
| 256 |
+
"""Efficient CNN designed for real-time inference with good performance."""
|
| 257 |
+
|
| 258 |
+
def __init__(self, input_length: int = 500, num_classes: int = 2):
|
| 259 |
+
super().__init__()
|
| 260 |
+
|
| 261 |
+
self.input_length = int(input_length)
|
| 262 |
+
self.num_classes = int(num_classes)
|
| 263 |
+
|
| 264 |
+
# Efficient feature extraction with depthwise separable convolutions
|
| 265 |
+
self.features = nn.Sequential(
|
| 266 |
+
# Initial convolution
|
| 267 |
+
nn.Conv1d(1, 32, kernel_size=7, padding=3),
|
| 268 |
+
nn.BatchNorm1d(32),
|
| 269 |
+
nn.ReLU(inplace=True),
|
| 270 |
+
nn.MaxPool1d(2),
|
| 271 |
+
# Depthwise separable convolutions
|
| 272 |
+
self._make_depthwise_sep_conv(32, 64),
|
| 273 |
+
nn.MaxPool1d(2),
|
| 274 |
+
self._make_depthwise_sep_conv(64, 96),
|
| 275 |
+
nn.MaxPool1d(2),
|
| 276 |
+
self._make_depthwise_sep_conv(96, 128),
|
| 277 |
+
nn.MaxPool1d(2),
|
| 278 |
+
# Final feature extraction
|
| 279 |
+
nn.Conv1d(128, 160, kernel_size=3, padding=1),
|
| 280 |
+
nn.BatchNorm1d(160),
|
| 281 |
+
nn.ReLU(inplace=True),
|
| 282 |
+
nn.AdaptiveAvgPool1d(1),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Lightweight classifier
|
| 286 |
+
self.classifier = nn.Sequential(
|
| 287 |
+
nn.Linear(160, 64),
|
| 288 |
+
nn.ReLU(inplace=True),
|
| 289 |
+
nn.Dropout(0.1),
|
| 290 |
+
nn.Linear(64, num_classes),
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
self._initialize_weights()
|
| 294 |
+
|
| 295 |
+
def _make_depthwise_sep_conv(self, in_channels, out_channels):
|
| 296 |
+
"""Create depthwise separable convolution block."""
|
| 297 |
+
return nn.Sequential(
|
| 298 |
+
# Depthwise convolution
|
| 299 |
+
nn.Conv1d(
|
| 300 |
+
in_channels, in_channels, kernel_size=3, padding=1, groups=in_channels
|
| 301 |
+
),
|
| 302 |
+
nn.BatchNorm1d(in_channels),
|
| 303 |
+
nn.ReLU(inplace=True),
|
| 304 |
+
# Pointwise convolution
|
| 305 |
+
nn.Conv1d(in_channels, out_channels, kernel_size=1),
|
| 306 |
+
nn.BatchNorm1d(out_channels),
|
| 307 |
+
nn.ReLU(inplace=True),
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
def _initialize_weights(self):
|
| 311 |
+
"""Initialize model weights."""
|
| 312 |
+
for m in self.modules():
|
| 313 |
+
if isinstance(m, nn.Conv1d):
|
| 314 |
+
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
|
| 315 |
+
if m.bias is not None:
|
| 316 |
+
nn.init.constant_(m.bias, 0)
|
| 317 |
+
elif isinstance(m, nn.Linear):
|
| 318 |
+
nn.init.xavier_uniform_(m.weight)
|
| 319 |
+
nn.init.constant_(m.bias, 0)
|
| 320 |
+
elif isinstance(m, nn.BatchNorm1d):
|
| 321 |
+
nn.init.constant_(m.weight, 1)
|
| 322 |
+
nn.init.constant_(m.bias, 0)
|
| 323 |
+
|
| 324 |
+
def forward(self, x):
|
| 325 |
+
if x.dim() == 2:
|
| 326 |
+
x = x.unsqueeze(1)
|
| 327 |
+
|
| 328 |
+
x = self.features(x)
|
| 329 |
+
x = x.view(x.size(0), -1)
|
| 330 |
+
x = self.classifier(x)
|
| 331 |
+
|
| 332 |
+
return x
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
class HybridSpectralNet(nn.Module):
|
| 336 |
+
"""Hybrid network combining CNN and attention mechanisms."""
|
| 337 |
+
|
| 338 |
+
def __init__(self, input_length: int = 500, num_classes: int = 2):
|
| 339 |
+
super().__init__()
|
| 340 |
+
|
| 341 |
+
self.input_length = int(input_length)
|
| 342 |
+
self.num_classes = int(num_classes)
|
| 343 |
+
|
| 344 |
+
# CNN backbone
|
| 345 |
+
self.cnn_backbone = nn.Sequential(
|
| 346 |
+
nn.Conv1d(1, 64, kernel_size=7, padding=3),
|
| 347 |
+
nn.BatchNorm1d(64),
|
| 348 |
+
nn.ReLU(inplace=True),
|
| 349 |
+
nn.MaxPool1d(2),
|
| 350 |
+
nn.Conv1d(64, 128, kernel_size=5, padding=2),
|
| 351 |
+
nn.BatchNorm1d(128),
|
| 352 |
+
nn.ReLU(inplace=True),
|
| 353 |
+
nn.MaxPool1d(2),
|
| 354 |
+
nn.Conv1d(128, 256, kernel_size=3, padding=1),
|
| 355 |
+
nn.BatchNorm1d(256),
|
| 356 |
+
nn.ReLU(inplace=True),
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
# Self-attention layer
|
| 360 |
+
self.attention = nn.MultiheadAttention(
|
| 361 |
+
embed_dim=256, num_heads=8, dropout=0.1, batch_first=True
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Final pooling and classification
|
| 365 |
+
self.global_pool = nn.AdaptiveAvgPool1d(1)
|
| 366 |
+
self.classifier = nn.Sequential(
|
| 367 |
+
nn.Linear(256, 128),
|
| 368 |
+
nn.ReLU(inplace=True),
|
| 369 |
+
nn.Dropout(0.2),
|
| 370 |
+
nn.Linear(128, num_classes),
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
def forward(self, x):
|
| 374 |
+
if x.dim() == 2:
|
| 375 |
+
x = x.unsqueeze(1)
|
| 376 |
+
|
| 377 |
+
# CNN feature extraction
|
| 378 |
+
x = self.cnn_backbone(x)
|
| 379 |
+
|
| 380 |
+
# Prepare for attention: [batch, length, channels]
|
| 381 |
+
x = x.transpose(1, 2)
|
| 382 |
+
|
| 383 |
+
# Self-attention
|
| 384 |
+
attn_out, _ = self.attention(x, x, x)
|
| 385 |
+
|
| 386 |
+
# Back to [batch, channels, length]
|
| 387 |
+
x = attn_out.transpose(1, 2)
|
| 388 |
+
|
| 389 |
+
# Global pooling and classification
|
| 390 |
+
x = self.global_pool(x)
|
| 391 |
+
x = x.view(x.size(0), -1)
|
| 392 |
+
x = self.classifier(x)
|
| 393 |
+
|
| 394 |
+
return x
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def create_enhanced_model(model_type: str = "enhanced", **kwargs):
|
| 398 |
+
"""Factory function to create enhanced models."""
|
| 399 |
+
models = {
|
| 400 |
+
"enhanced": EnhancedCNN,
|
| 401 |
+
"efficient": EfficientSpectralCNN,
|
| 402 |
+
"hybrid": HybridSpectralNet,
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
if model_type not in models:
|
| 406 |
+
raise ValueError(
|
| 407 |
+
f"Unknown model type: {model_type}. Available: {list(models.keys())}"
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
return models[model_type](**kwargs)
|
backend/models/figure2_cnn.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📌 MODEL DESIGNATION:
|
| 2 |
+
# Figure2CNN is validated ONLY for RAMAN spectra input.
|
| 3 |
+
# Any use for FTIR modeling is invalid and deprecated.
|
| 4 |
+
# See milestone: @figure2cnn-raman-only-milestone
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Figure2CNN(nn.Module):
|
| 11 |
+
"""
|
| 12 |
+
CNN architecture based on Figure 2 of the referenced research paper.
|
| 13 |
+
Designed for 1D spectral data input of length 500
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, input_length=500, input_channels=1):
|
| 17 |
+
super(Figure2CNN, self).__init__()
|
| 18 |
+
|
| 19 |
+
self.input_channels = input_channels
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
self.conv_block = nn.Sequential(
|
| 23 |
+
nn.Conv1d(input_channels, 16, kernel_size=5, padding=2),
|
| 24 |
+
nn.ReLU(),
|
| 25 |
+
nn.MaxPool1d(kernel_size=2),
|
| 26 |
+
|
| 27 |
+
nn.Conv1d(16, 32, kernel_size=5, padding=2),
|
| 28 |
+
nn.ReLU(),
|
| 29 |
+
nn.MaxPool1d(kernel_size=2),
|
| 30 |
+
|
| 31 |
+
nn.Conv1d(32, 64, kernel_size=5, padding=2),
|
| 32 |
+
nn.ReLU(),
|
| 33 |
+
nn.MaxPool1d(kernel_size=2),
|
| 34 |
+
|
| 35 |
+
nn.Conv1d(64, 128, kernel_size=5, padding=2),
|
| 36 |
+
nn.ReLU(),
|
| 37 |
+
nn.MaxPool1d(kernel_size=2),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Dynamically calculate flattened size after conv + pooling
|
| 41 |
+
self.flattened_size = self._get_flattened_size(input_channels, input_length)
|
| 42 |
+
|
| 43 |
+
self.classifier = nn.Sequential(
|
| 44 |
+
nn.Linear(self.flattened_size, 256),
|
| 45 |
+
nn.ReLU(),
|
| 46 |
+
nn.Linear(256, 128),
|
| 47 |
+
nn.ReLU(),
|
| 48 |
+
nn.Linear(128, 2) # Binary output
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def _get_flattened_size(self,input_channels, input_length):
|
| 52 |
+
with torch.no_grad():
|
| 53 |
+
dummy_input = torch.zeros(1, input_channels, input_length)
|
| 54 |
+
out = self.conv_block(dummy_input)
|
| 55 |
+
return out.view(1, -1).shape[1]
|
| 56 |
+
|
| 57 |
+
def forward(self, x):
|
| 58 |
+
"""
|
| 59 |
+
Defines the forward pass of the Figure2CNN model.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
x (torch.Tensor): Input tensor of shape (batch_size, channels, input_length).
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
torch.Tensor: Output tensor containing class scores.
|
| 66 |
+
"""
|
| 67 |
+
x = self.conv_block(x)
|
| 68 |
+
x = x.view(x.size(0), -1) # Flatten
|
| 69 |
+
return self.classifier(x)
|
| 70 |
+
|
| 71 |
+
def describe_model(self):
|
| 72 |
+
"""Print architecture and flattened size (for debug). """
|
| 73 |
+
print(r"\n Model Summary:")
|
| 74 |
+
print(r" - Conv Block: 4 Layers")
|
| 75 |
+
print(f" - Input length: {self.flattened_size} after conv/pool")
|
| 76 |
+
print(f" - Classifier: {self.classifier}\n")
|
| 77 |
+
|
backend/models/registry.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models/registry.py
|
| 2 |
+
from typing import Callable, Dict, List, Any
|
| 3 |
+
from .figure2_cnn import Figure2CNN
|
| 4 |
+
from .resnet_cnn import ResNet1D
|
| 5 |
+
from .resnet18_vision import ResNet18Vision
|
| 6 |
+
from .enhanced_cnn import EnhancedCNN, EfficientSpectralCNN, HybridSpectralNet
|
| 7 |
+
|
| 8 |
+
def _resolve_key(name: str, registry: Dict[str, Any]) -> str:
|
| 9 |
+
"""Resolve a case-insensitive model name to its canonical key."""
|
| 10 |
+
for key in registry:
|
| 11 |
+
if key.lower() == name.lower():
|
| 12 |
+
return key
|
| 13 |
+
raise KeyError(f"Unknown model '{name}'. Available: {list(registry.keys())}")
|
| 14 |
+
|
| 15 |
+
# Internal registry of model builders keyed by short name.
|
| 16 |
+
_REGISTRY: Dict[str, Callable[[int], object]] = {
|
| 17 |
+
"Figure2": lambda L: Figure2CNN(input_length=L),
|
| 18 |
+
"ResNet": lambda L: ResNet1D(input_length=L),
|
| 19 |
+
"ResNet18Vision": lambda L: ResNet18Vision(input_length=L),
|
| 20 |
+
"Enhanced_cnn": lambda L: EnhancedCNN(input_length=L),
|
| 21 |
+
"Efficient_cnn": lambda L: EfficientSpectralCNN(input_length=L),
|
| 22 |
+
"Hybrid_Net": lambda L: HybridSpectralNet(input_length=L),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
# Model specifications with metadata for enhanced features
|
| 26 |
+
_MODEL_SPECS: Dict[str, Dict[str, Any]] = {
|
| 27 |
+
"Figure2": {
|
| 28 |
+
"input_length": 500,
|
| 29 |
+
"num_classes": 2,
|
| 30 |
+
"description": "Figure 2 baseline custom implementation",
|
| 31 |
+
"modalities": ["raman", "ftir"],
|
| 32 |
+
"citation": "Neo et al., 2023, Resour. Conserv. Recycl., 188, 106718",
|
| 33 |
+
"performance": {"accuracy": 0.948, "f1_score": 0.943},
|
| 34 |
+
"parameters": "~500K",
|
| 35 |
+
"speed": "fast",
|
| 36 |
+
},
|
| 37 |
+
"ResNet": {
|
| 38 |
+
"input_length": 500,
|
| 39 |
+
"num_classes": 2,
|
| 40 |
+
"description": "(Residual Network) uses skip connections to train much deeper networks",
|
| 41 |
+
"modalities": ["raman", "ftir"],
|
| 42 |
+
"citation": "Custom ResNet implementation",
|
| 43 |
+
"performance": {"accuracy": 0.962, "f1_score": 0.959},
|
| 44 |
+
"parameters": "~100K",
|
| 45 |
+
"speed": "very_fast",
|
| 46 |
+
},
|
| 47 |
+
"ResNet18Vision": {
|
| 48 |
+
"input_length": 500,
|
| 49 |
+
"num_classes": 2,
|
| 50 |
+
"description": "excels at image recognition tasks by using 'residual blocks' to train more efficiently",
|
| 51 |
+
"modalities": ["raman", "ftir"],
|
| 52 |
+
"citation": "ResNet18 Vision adaptation",
|
| 53 |
+
"performance": {"accuracy": 0.945, "f1_score": 0.940},
|
| 54 |
+
"parameters": "~11M",
|
| 55 |
+
"speed": "medium",
|
| 56 |
+
},
|
| 57 |
+
"Enhanced_cnn": {
|
| 58 |
+
"input_length": 500,
|
| 59 |
+
"num_classes": 2,
|
| 60 |
+
"description": "Enhanced CNN with attention mechanisms and multi-scale feature extraction",
|
| 61 |
+
"modalities": ["raman", "ftir"],
|
| 62 |
+
"citation": "Custom enhanced architecture with attention",
|
| 63 |
+
"performance": {"accuracy": 0.975, "f1_score": 0.973},
|
| 64 |
+
"parameters": "~800K",
|
| 65 |
+
"speed": "medium",
|
| 66 |
+
"features": ["attention", "multi_scale", "batch_norm", "dropout"],
|
| 67 |
+
},
|
| 68 |
+
"Efficient_cnn": {
|
| 69 |
+
"input_length": 500,
|
| 70 |
+
"num_classes": 2,
|
| 71 |
+
"description": "Efficient CNN optimized for real-time inference with depthwise separable convolutions",
|
| 72 |
+
"modalities": ["raman", "ftir"],
|
| 73 |
+
"citation": "Custom efficient architecture",
|
| 74 |
+
"performance": {"accuracy": 0.955, "f1_score": 0.952},
|
| 75 |
+
"parameters": "~200K",
|
| 76 |
+
"speed": "very_fast",
|
| 77 |
+
"features": ["depthwise_separable", "lightweight", "real_time"],
|
| 78 |
+
},
|
| 79 |
+
"Hybrid_Net": {
|
| 80 |
+
"input_length": 500,
|
| 81 |
+
"num_classes": 2,
|
| 82 |
+
"description": "Hybrid network combining CNN backbone with self-attention mechanisms",
|
| 83 |
+
"modalities": ["raman", "ftir"],
|
| 84 |
+
"citation": "Custom hybrid CNN-Transformer architecture",
|
| 85 |
+
"performance": {"accuracy": 0.968, "f1_score": 0.965},
|
| 86 |
+
"parameters": "~1.2M",
|
| 87 |
+
"speed": "medium",
|
| 88 |
+
"features": ["self_attention", "cnn_backbone", "transformer_head"],
|
| 89 |
+
},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Placeholder for future model expansions
|
| 93 |
+
_FUTURE_MODELS = {
|
| 94 |
+
"densenet1d": {
|
| 95 |
+
"description": "DenseNet1D for spectroscopy with dense connections",
|
| 96 |
+
"status": "planned",
|
| 97 |
+
"modalities": ["raman", "ftir"],
|
| 98 |
+
"features": ["dense_connections", "parameter_efficient"],
|
| 99 |
+
},
|
| 100 |
+
"ensemble_cnn": {
|
| 101 |
+
"description": "Ensemble of multiple CNN variants for robust predictions",
|
| 102 |
+
"status": "planned",
|
| 103 |
+
"modalities": ["raman", "ftir"],
|
| 104 |
+
"features": ["ensemble", "robust", "high_accuracy"],
|
| 105 |
+
},
|
| 106 |
+
"vision_transformer": {
|
| 107 |
+
"description": "Vision Transformer adapted for 1D spectral data",
|
| 108 |
+
"status": "planned",
|
| 109 |
+
"modalities": ["raman", "ftir"],
|
| 110 |
+
"features": ["transformer", "attention", "state_of_art"],
|
| 111 |
+
},
|
| 112 |
+
"autoencoder_cnn": {
|
| 113 |
+
"description": "CNN with autoencoder for unsupervised feature learning",
|
| 114 |
+
"status": "planned",
|
| 115 |
+
"modalities": ["raman", "ftir"],
|
| 116 |
+
"features": ["autoencoder", "unsupervised", "feature_learning"],
|
| 117 |
+
},
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def choices():
|
| 122 |
+
"""Return the list of available model keys."""
|
| 123 |
+
return list(_REGISTRY.keys())
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def planned_models():
|
| 127 |
+
"""Return the list of planned future model keys."""
|
| 128 |
+
return list(_FUTURE_MODELS.keys())
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def build(name: str, input_length: int):
|
| 132 |
+
"""Instantiate a model by short name with the given input length."""
|
| 133 |
+
key = _resolve_key(name, _REGISTRY)
|
| 134 |
+
return _REGISTRY[key](input_length)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def build_multiple(names: List[str], input_length: int) -> Dict[str, Any]:
|
| 138 |
+
"""Nuild multiple models for comparison."""
|
| 139 |
+
models = {}
|
| 140 |
+
for name in names:
|
| 141 |
+
key = _resolve_key(name, _REGISTRY)
|
| 142 |
+
models[key] = _REGISTRY[key](input_length)
|
| 143 |
+
return models
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def register_model(
|
| 147 |
+
name: str, builder: Callable[[int], object], spec: Dict[str, Any]
|
| 148 |
+
) -> None:
|
| 149 |
+
"""Dynamically register a new model."""
|
| 150 |
+
if not callable(builder):
|
| 151 |
+
raise TypeError("Builder must be a callable that accepts an integer argument.")
|
| 152 |
+
try:
|
| 153 |
+
existing_key = _resolve_key(name, _REGISTRY)
|
| 154 |
+
raise ValueError(f"Model '{name}' already registered as '{existing_key}'.")
|
| 155 |
+
except KeyError:
|
| 156 |
+
_REGISTRY[name] = builder
|
| 157 |
+
_MODEL_SPECS[name] = spec
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def registry_spec(name: str):
|
| 161 |
+
"""Return expected input length and number of classes for a model key."""
|
| 162 |
+
key = _resolve_key(name, _MODEL_SPECS)
|
| 163 |
+
return _MODEL_SPECS[key].copy()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def get_model_info(name: str) -> Dict[str, Any]:
|
| 167 |
+
"""Get comprehensive model information including metadata."""
|
| 168 |
+
try:
|
| 169 |
+
key = _resolve_key(name, _MODEL_SPECS)
|
| 170 |
+
return _MODEL_SPECS[key].copy()
|
| 171 |
+
except KeyError:
|
| 172 |
+
try:
|
| 173 |
+
key = _resolve_key(name, _FUTURE_MODELS)
|
| 174 |
+
return _FUTURE_MODELS[key].copy()
|
| 175 |
+
except KeyError:
|
| 176 |
+
raise KeyError(f"Unknown model '{name}'")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def models_for_modality(modality: str) -> List[str]:
|
| 180 |
+
"""Get list of models that support a specific modality."""
|
| 181 |
+
compatible = []
|
| 182 |
+
for name, spec_info in _MODEL_SPECS.items():
|
| 183 |
+
if modality in spec_info.get("modalities", []):
|
| 184 |
+
compatible.append(name)
|
| 185 |
+
return compatible
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def validate_model_list(names: List[str]) -> List[str]:
|
| 189 |
+
"""Validate and return list of available models from input list."""
|
| 190 |
+
valid_models = []
|
| 191 |
+
for name in names:
|
| 192 |
+
try:
|
| 193 |
+
key = _resolve_key(name, _REGISTRY)
|
| 194 |
+
valid_models.append(key)
|
| 195 |
+
except KeyError:
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_models_metadata() -> Dict[str, Dict[str, Any]]:
|
| 200 |
+
"""Get metadata for all registered models."""
|
| 201 |
+
return {name: _MODEL_SPECS[name].copy() for name in _MODEL_SPECS}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def is_model_compatible(name: str, modality: str) -> bool:
|
| 205 |
+
"""Check if a model is compatible with a specific modality."""
|
| 206 |
+
try:
|
| 207 |
+
key = _resolve_key(name, _MODEL_SPECS)
|
| 208 |
+
return modality in _MODEL_SPECS[key].get("modalities", [])
|
| 209 |
+
except KeyError:
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def get_model_capabilities(name: str) -> Dict[str, Any]:
|
| 214 |
+
"""Get detailed capabilities of a model."""
|
| 215 |
+
key = _resolve_key(name, _MODEL_SPECS)
|
| 216 |
+
spec = _MODEL_SPECS[key].copy()
|
| 217 |
+
spec.update(
|
| 218 |
+
{
|
| 219 |
+
"available": True,
|
| 220 |
+
"status": "active",
|
| 221 |
+
"supported_tasks": ["binary_classification"],
|
| 222 |
+
"performance_metrics": {
|
| 223 |
+
"supports_confidence": True,
|
| 224 |
+
"supports_batch": True,
|
| 225 |
+
"memory_efficient": spec.get("description", "").lower().find("resnet")
|
| 226 |
+
!= -1,
|
| 227 |
+
},
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
return spec
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
__all__ = [
|
| 234 |
+
"choices",
|
| 235 |
+
"build",
|
| 236 |
+
"registry_spec",
|
| 237 |
+
"build_multiple",
|
| 238 |
+
"register_model",
|
| 239 |
+
"get_model_info",
|
| 240 |
+
"models_for_modality",
|
| 241 |
+
"validate_model_list",
|
| 242 |
+
"planned_models",
|
| 243 |
+
"get_models_metadata",
|
| 244 |
+
"is_model_compatible",
|
| 245 |
+
"get_model_capabilities",
|
| 246 |
+
]
|
backend/models/resnet18_vision.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models/resnet18_vision.py
|
| 2 |
+
# 1D ResNet-18 style model for spectra: input (B, 1, L)
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
from typing import Callable, List
|
| 6 |
+
|
| 7 |
+
class BasicBlock1D(nn.Module):
|
| 8 |
+
expansion = 1
|
| 9 |
+
def __init__(self, in_planes: int, planes: int, stride: int = 1, downsample: nn.Module | None = None):
|
| 10 |
+
super().__init__()
|
| 11 |
+
self.conv1 = nn.Conv1d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
|
| 12 |
+
self.bn1 = nn.BatchNorm1d(planes)
|
| 13 |
+
self.relu = nn.ReLU(inplace=True)
|
| 14 |
+
self.conv2 = nn.Conv1d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
|
| 15 |
+
self.bn2 = nn.BatchNorm1d(planes)
|
| 16 |
+
self.downsample = downsample
|
| 17 |
+
|
| 18 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 19 |
+
identity = x
|
| 20 |
+
out = self.relu(self.bn1(self.conv1(x)))
|
| 21 |
+
out = self.bn2(self.conv2(out))
|
| 22 |
+
if self.downsample is not None:
|
| 23 |
+
identity = self.downsample(x)
|
| 24 |
+
out += identity
|
| 25 |
+
out = self.relu(out)
|
| 26 |
+
return out
|
| 27 |
+
|
| 28 |
+
def _make_layer(block: Callable[..., nn.Module], in_planes: int, planes: int, blocks: int, stride: int) -> nn.Sequential:
|
| 29 |
+
downsample = None
|
| 30 |
+
if stride != 1 or in_planes != planes * block.expansion:
|
| 31 |
+
downsample = nn.Sequential(
|
| 32 |
+
nn.Conv1d(in_planes, planes * block.expansion, kernel_size=1, stride=stride, bias=False),
|
| 33 |
+
nn.BatchNorm1d(planes * block.expansion),
|
| 34 |
+
)
|
| 35 |
+
layers: List[nn.Module] = [block(in_planes, planes, stride, downsample)]
|
| 36 |
+
in_planes = planes * block.expansion
|
| 37 |
+
for _ in range(1, blocks):
|
| 38 |
+
layers.append(block(in_planes, planes))
|
| 39 |
+
return nn.Sequential(*layers)
|
| 40 |
+
|
| 41 |
+
class ResNet18Vision(nn.Module):
|
| 42 |
+
def __init__(self, input_length: int = 500, num_classes: int = 2):
|
| 43 |
+
super().__init__()
|
| 44 |
+
|
| 45 |
+
self.input_length = int(input_length)
|
| 46 |
+
self.num_classes = int(num_classes)
|
| 47 |
+
|
| 48 |
+
# 1D stem
|
| 49 |
+
self.conv1 = nn.Conv1d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
|
| 50 |
+
self.bn1 = nn.BatchNorm1d(64)
|
| 51 |
+
self.relu = nn.ReLU(inplace=True)
|
| 52 |
+
self.maxpool = nn.MaxPool1d(kernel_size=3, stride=2, padding=1)
|
| 53 |
+
|
| 54 |
+
# ResNet-18: 2 blocks per layer
|
| 55 |
+
self.layer1 = _make_layer(BasicBlock1D, 64, 64, blocks=2, stride=1)
|
| 56 |
+
self.layer2 = _make_layer(BasicBlock1D, 64, 128, blocks=2, stride=2)
|
| 57 |
+
self.layer3 = _make_layer(BasicBlock1D, 128, 256, blocks=2, stride=2)
|
| 58 |
+
self.layer4 = _make_layer(BasicBlock1D, 256, 512, blocks=2, stride=2)
|
| 59 |
+
|
| 60 |
+
# Global pooling + classifier
|
| 61 |
+
self.avgpool = nn.AdaptiveAvgPool1d(1)
|
| 62 |
+
self.fc = nn.Linear(512 * BasicBlock1D.expansion, num_classes)
|
| 63 |
+
|
| 64 |
+
# Kaiming init
|
| 65 |
+
for m in self.modules():
|
| 66 |
+
if isinstance(m, nn.Conv1d):
|
| 67 |
+
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
|
| 68 |
+
elif isinstance(m, (nn.BatchNorm1d, nn.GroupNorm)):
|
| 69 |
+
nn.init.ones_(m.weight); nn.init.zeros_(m.bias)
|
| 70 |
+
|
| 71 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 72 |
+
# x: (B, 1, L)
|
| 73 |
+
x = self.relu(self.bn1(self.conv1(x)))
|
| 74 |
+
x = self.maxpool(x)
|
| 75 |
+
x = self.layer1(x)
|
| 76 |
+
x = self.layer2(x)
|
| 77 |
+
x = self.layer3(x)
|
| 78 |
+
x = self.layer4(x)
|
| 79 |
+
x = self.avgpool(x) # (B, C, 1)
|
| 80 |
+
x = torch.flatten(x, 1) # (B, C)
|
| 81 |
+
x = self.fc(x) # (B, num_classes)
|
| 82 |
+
return x
|
backend/models/resnet_cnn.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
📌 MODEL DESIGNATION:
|
| 3 |
+
Figure2CNN is validated ONLY for RAMAN spectra input.
|
| 4 |
+
Any use for FTIR modeling is invalid and deprecated.
|
| 5 |
+
See milestone: @figure2cnn-raman-only-milestone
|
| 6 |
+
"""
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ResidualBlock1D(nn.Module):
|
| 12 |
+
"""
|
| 13 |
+
Basic 1-D residual block:
|
| 14 |
+
Conv1d -> ReLU -> Conv1d (+ skip connection).
|
| 15 |
+
If channel count changes, a 1x1 Conv aligns the skip path.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3):
|
| 19 |
+
super().__init__()
|
| 20 |
+
padding = kernel_size // 2
|
| 21 |
+
|
| 22 |
+
self.conv1 = nn.Conv1d(in_channels, out_channels,
|
| 23 |
+
kernel_size, padding=padding)
|
| 24 |
+
self.relu = nn.ReLU(inplace=True)
|
| 25 |
+
self.conv2 = nn.Conv1d(out_channels, out_channels,
|
| 26 |
+
kernel_size, padding=padding)
|
| 27 |
+
|
| 28 |
+
self.skip = (
|
| 29 |
+
nn.Identity()
|
| 30 |
+
if in_channels == out_channels
|
| 31 |
+
else nn.Conv1d(in_channels, out_channels, kernel_size=1)
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 35 |
+
identity = self.skip(x)
|
| 36 |
+
out = self.relu(self.conv1(x))
|
| 37 |
+
out = self.conv2(out)
|
| 38 |
+
return self.relu(out + identity)
|
| 39 |
+
|
| 40 |
+
def describe_model(self):
|
| 41 |
+
"""Print architecture and flattened size (for debug). """
|
| 42 |
+
print(r"\n Model Summary:")
|
| 43 |
+
print(r" - Conv Block: 4 Layers")
|
| 44 |
+
print(f" - Input length: {self.flattened_size} after conv/pool")
|
| 45 |
+
print(f" - Classifier: {self.classifier}\n")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ResNet1D(nn.Module):
|
| 49 |
+
"""
|
| 50 |
+
Lightweight 1-D ResNet for Raman spectra (length 500, single channel).
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, input_length: int = 500, num_classes: int = 2):
|
| 54 |
+
super().__init__()
|
| 55 |
+
|
| 56 |
+
self.input_length = int(input_length)
|
| 57 |
+
self.num_classes = int(num_classes)
|
| 58 |
+
|
| 59 |
+
# Three residual stages
|
| 60 |
+
self.stage1 = ResidualBlock1D(1, 16)
|
| 61 |
+
self.stage2 = ResidualBlock1D(16, 32)
|
| 62 |
+
self.stage3 = ResidualBlock1D(32, 64)
|
| 63 |
+
|
| 64 |
+
# Global aggregation + classifier
|
| 65 |
+
self.global_pool = nn.AdaptiveAvgPool1d(1) # -> [B, 64, 1]
|
| 66 |
+
self.fc = nn.Linear(64, num_classes)
|
| 67 |
+
|
| 68 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 69 |
+
x = self.stage1(x)
|
| 70 |
+
x = self.stage2(x)
|
| 71 |
+
x = self.stage3(x)
|
| 72 |
+
x = self.global_pool(x).squeeze(-1) # -> [B, 64]
|
| 73 |
+
return self.fc(x)
|
backend/models/weights/efficient_cnn_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:08ae3befe95b73d80111f669e040d2b185c05e63043850644b9765a4c3013a7d
|
| 3 |
+
size 405858
|
backend/models/weights/enhanced_cnn_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e3d05e9826be3690d5c906a3a814b21d4d778a6cf3f290cd2a1342db8d8dab59
|
| 3 |
+
size 1741892
|
backend/models/weights/figure2_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:852247bf0540aa947c9887a7e004c0858d622cfa0413e9b26bd9f5dab359ad5e
|
| 3 |
+
size 4418520
|
backend/models/weights/hybrid_net_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c6ae29a09550a7cd2bcf6aa63585e8b7713f8d438b41a6e7ac99a7dc0a4334af
|
| 3 |
+
size 1762856
|
backend/models/weights/resnet18vision_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8e08016742f05a0e3d34270a885b67ef0b6d938fcbe8b8ab83256fc0ff1d019d
|
| 3 |
+
size 15458340
|
backend/models/weights/resnet_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4f1d1b5541ade480077eeae8c627b8e2372076cc52f0be4e69a3b063895653a9
|
| 3 |
+
size 114450
|
backend/pydantic_models.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=unused-import
|
| 2 |
+
"""
|
| 3 |
+
Pydantic models for API request/response validation.
|
| 4 |
+
Maintains strict contract between React frontend and FastAPI backend.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import List, Dict, Any, Optional, Union, Literal
|
| 9 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SpectrumData(BaseModel):
|
| 14 |
+
"""Single spectrum data for analysis"""
|
| 15 |
+
|
| 16 |
+
x_values: List[float] = Field(..., description="Wavenumber values (cm⁻¹)")
|
| 17 |
+
y_values: List[float] = Field(..., description="Intensity values")
|
| 18 |
+
filename: Optional[str] = Field(None, description="Original filename")
|
| 19 |
+
|
| 20 |
+
@field_validator("x_values", "y_values")
|
| 21 |
+
@classmethod
|
| 22 |
+
def validate_arrays(cls, v: List[float]) -> List[float]:
|
| 23 |
+
"""
|
| 24 |
+
Validate that the input arrays have at least 2 values.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
v (list): The array to validate.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
list: The validated array.
|
| 31 |
+
|
| 32 |
+
Raises:
|
| 33 |
+
ValueError: If the array has fewer than 2 values.
|
| 34 |
+
"""
|
| 35 |
+
if len(v) < 2:
|
| 36 |
+
raise ValueError("Arrays must have at least 2 values")
|
| 37 |
+
return v
|
| 38 |
+
|
| 39 |
+
@model_validator(mode="after")
|
| 40 |
+
def validate_equal_length(self) -> "SpectrumData":
|
| 41 |
+
"""
|
| 42 |
+
Ensure that y_values has the same length as x_values.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
v (list): The y_values list to validate.
|
| 46 |
+
values (dict): The dictionary containing other field values.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
list: The validated y_values list.
|
| 50 |
+
|
| 51 |
+
Raises:
|
| 52 |
+
ValueError: If y_values and x_values do not have the same length.
|
| 53 |
+
"""
|
| 54 |
+
if len(self.x_values) != len(self.y_values):
|
| 55 |
+
raise ValueError("x_values and y_values must have equal length")
|
| 56 |
+
return self
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AnalysisRequest(BaseModel):
|
| 60 |
+
"""Request for single spectrum analysis"""
|
| 61 |
+
|
| 62 |
+
spectrum: SpectrumData
|
| 63 |
+
model_name: str = Field(..., description="Model name to use for analysis")
|
| 64 |
+
modality: Literal["raman", "ftir"] = Field(
|
| 65 |
+
"raman", description="Spectroscopy modality"
|
| 66 |
+
)
|
| 67 |
+
include_provenance: bool = Field(
|
| 68 |
+
True, description="Include full provenance metadata"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class BatchAnalysisRequest(BaseModel):
|
| 73 |
+
"""Request for batch spectrum analysis"""
|
| 74 |
+
|
| 75 |
+
spectra: List[SpectrumData] = Field(..., min_length=1, max_length=100)
|
| 76 |
+
model_name: str = Field(..., description="Model name to use for analysis")
|
| 77 |
+
modality: Literal["raman", "ftir"] = Field(
|
| 78 |
+
"raman", description="Spectroscopy modality"
|
| 79 |
+
)
|
| 80 |
+
include_provenance: bool = Field(
|
| 81 |
+
True, description="Include full provenance metadata"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ComparisonRequest(BaseModel):
|
| 86 |
+
"""Request for multi-model comparison"""
|
| 87 |
+
|
| 88 |
+
spectrum: SpectrumData
|
| 89 |
+
model_names: Optional[List[str]] = Field(
|
| 90 |
+
None, description="Models to compare (all if None)"
|
| 91 |
+
)
|
| 92 |
+
modality: Literal["raman", "ftir"] = Field(
|
| 93 |
+
"raman", description="Spectroscopy modality"
|
| 94 |
+
)
|
| 95 |
+
include_provenance: bool = Field(
|
| 96 |
+
True, description="Include full provenance metadata"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class PreprocessingMetadata(BaseModel):
|
| 101 |
+
"""Preprocessing provenance metadata"""
|
| 102 |
+
|
| 103 |
+
target_length: int = Field(..., description="Target resampling length")
|
| 104 |
+
baseline_degree: int = Field(...,
|
| 105 |
+
description="Polynomial baseline removal degree")
|
| 106 |
+
smooth_window: int = Field(..., description="Smoothing window length")
|
| 107 |
+
smooth_polyorder: int = Field(...,
|
| 108 |
+
description="Smoothing polynomial order")
|
| 109 |
+
normalization_method: str = Field(...,
|
| 110 |
+
description="Normalization method applied")
|
| 111 |
+
modality_validated: bool = Field(
|
| 112 |
+
..., description="Whether modality validation passed"
|
| 113 |
+
)
|
| 114 |
+
validation_issues: List[str] = Field(
|
| 115 |
+
default_factory=list, description="Any validation issues found"
|
| 116 |
+
)
|
| 117 |
+
original_length: int = Field(..., description="Original spectrum length")
|
| 118 |
+
wavenumber_range: List[float] = Field(
|
| 119 |
+
..., min_length=2, max_length=2, description="[min, max] wavenumber range"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class QualityControlMetadata(BaseModel):
|
| 124 |
+
"""Quality control check results"""
|
| 125 |
+
|
| 126 |
+
signal_to_noise_ratio: Optional[float] = Field(
|
| 127 |
+
None, description="Estimated SNR")
|
| 128 |
+
baseline_stability: Optional[float] = Field(
|
| 129 |
+
None, description="Baseline stability metric"
|
| 130 |
+
)
|
| 131 |
+
spectral_resolution: Optional[float] = Field(
|
| 132 |
+
None, description="Estimated spectral resolution"
|
| 133 |
+
)
|
| 134 |
+
cosmic_ray_detected: bool = Field(
|
| 135 |
+
False, description="Cosmic ray spikes detected")
|
| 136 |
+
saturation_detected: bool = Field(
|
| 137 |
+
False, description="Signal saturation detected")
|
| 138 |
+
issues: List[str] = Field(default_factory=list,
|
| 139 |
+
description="QC issues found")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class ModelMetadata(BaseModel):
|
| 143 |
+
"""Model metadata and calibration details"""
|
| 144 |
+
|
| 145 |
+
model_name: str = Field(..., description="Model identifier")
|
| 146 |
+
model_description: str = Field(..., description="Model description")
|
| 147 |
+
model_version: Optional[str] = Field(None, description="Model version")
|
| 148 |
+
training_date: Optional[str] = Field(
|
| 149 |
+
None, description="Model training date")
|
| 150 |
+
input_length: int = Field(..., description="Expected input length")
|
| 151 |
+
num_classes: int = Field(..., description="Number of output classes")
|
| 152 |
+
parameters_count: Optional[str] = Field(
|
| 153 |
+
None, description="Number of parameters")
|
| 154 |
+
performance_metrics: Dict[str, float] = Field(
|
| 155 |
+
default_factory=dict, description="Training performance"
|
| 156 |
+
)
|
| 157 |
+
supported_modalities: List[str] = Field(
|
| 158 |
+
default_factory=list, description="Supported spectroscopy modalities"
|
| 159 |
+
)
|
| 160 |
+
citation: Optional[str] = Field(
|
| 161 |
+
None, description="Model citation/reference")
|
| 162 |
+
weights_loaded: bool = Field(...,
|
| 163 |
+
description="Whether trained weights were loaded")
|
| 164 |
+
weights_path: Optional[str] = Field(
|
| 165 |
+
None, description="Path to loaded weights")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class PredictionResult(BaseModel):
|
| 169 |
+
"""Single prediction result with full provenance"""
|
| 170 |
+
|
| 171 |
+
prediction: int = Field(...,
|
| 172 |
+
description="Predicted class (0=Stable, 1=Weathered)")
|
| 173 |
+
prediction_label: str = Field(...,
|
| 174 |
+
description="Human-readable prediction label")
|
| 175 |
+
confidence: float = Field(
|
| 176 |
+
..., ge=0.0, le=1.0, description="Prediction confidence score"
|
| 177 |
+
)
|
| 178 |
+
probabilities: List[float] = Field(..., description="Class probabilities")
|
| 179 |
+
logits: List[float] = Field(..., description="Raw model logits")
|
| 180 |
+
|
| 181 |
+
# Provenance metadata
|
| 182 |
+
preprocessing: PreprocessingMetadata
|
| 183 |
+
quality_control: QualityControlMetadata
|
| 184 |
+
model_metadata: ModelMetadata
|
| 185 |
+
|
| 186 |
+
# Performance tracking
|
| 187 |
+
inference_time: float = Field(..., ge=0.0,
|
| 188 |
+
description="Inference time in seconds")
|
| 189 |
+
preprocessing_time: float = Field(
|
| 190 |
+
..., ge=0.0, description="Preprocessing time in seconds"
|
| 191 |
+
)
|
| 192 |
+
total_time: float = Field(
|
| 193 |
+
..., ge=0.0, description="Total processing time in seconds"
|
| 194 |
+
)
|
| 195 |
+
memory_usage_mb: float = Field(..., ge=0.0,
|
| 196 |
+
description="Memory usage in MB")
|
| 197 |
+
|
| 198 |
+
# Input data (for audit trail)
|
| 199 |
+
original_spectrum: SpectrumData
|
| 200 |
+
processed_spectrum: SpectrumData
|
| 201 |
+
|
| 202 |
+
# Timestamps
|
| 203 |
+
timestamp: str = Field(...,
|
| 204 |
+
description="Processing timestamp (ISO format)")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class BatchError(BaseModel):
|
| 208 |
+
"""Details of a single error within a batch request"""
|
| 209 |
+
|
| 210 |
+
filename: Optional[str] = Field(
|
| 211 |
+
None, description="Filename of the spectrum that failed"
|
| 212 |
+
)
|
| 213 |
+
error: str = Field(..., description="The error message")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class BatchPredictionResult(BaseModel):
|
| 217 |
+
"""Batch prediction results"""
|
| 218 |
+
|
| 219 |
+
results: List[PredictionResult] = Field(
|
| 220 |
+
default_factory=list, description="Individual prediction results"
|
| 221 |
+
)
|
| 222 |
+
errors: List[BatchError] = Field(
|
| 223 |
+
default_factory=list,
|
| 224 |
+
description="Errors for spectra that failed processing",
|
| 225 |
+
)
|
| 226 |
+
summary: Dict[str, Any] = Field(
|
| 227 |
+
default_factory=dict, description="Batch summary statistics"
|
| 228 |
+
)
|
| 229 |
+
total_processing_time: float = Field(
|
| 230 |
+
..., ge=0.0, description="Total batch processing time"
|
| 231 |
+
)
|
| 232 |
+
timestamp: str = Field(..., description="Batch processing timestamp")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class ComparisonResult(BaseModel):
|
| 236 |
+
"""Multi-model comparison results"""
|
| 237 |
+
|
| 238 |
+
spectrum_id: str = Field(...,
|
| 239 |
+
description="Unique identifier for the spectrum")
|
| 240 |
+
model_results: Dict[str, PredictionResult] = Field(
|
| 241 |
+
default_factory=dict, description="Results per model"
|
| 242 |
+
)
|
| 243 |
+
consensus_prediction: Optional[int] = Field(
|
| 244 |
+
None, description="Consensus prediction if available"
|
| 245 |
+
)
|
| 246 |
+
confidence_variance: float = Field(
|
| 247 |
+
..., ge=0.0, description="Variance in confidence scores"
|
| 248 |
+
)
|
| 249 |
+
agreement_score: float = Field(
|
| 250 |
+
..., ge=0.0, le=1.0, description="Model agreement score"
|
| 251 |
+
)
|
| 252 |
+
timestamp: str = Field(..., description="Comparison timestamp")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class FeatureImportanceSummary(BaseModel):
|
| 256 |
+
"""Summary of feature importance scores"""
|
| 257 |
+
max_importance: float
|
| 258 |
+
mean_importance: float
|
| 259 |
+
important_region_start: int
|
| 260 |
+
important_region_end: int
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class TopFeatures(BaseModel):
|
| 264 |
+
"""Top features identified by explainability analysis"""
|
| 265 |
+
indices: List[int]
|
| 266 |
+
values: List[float]
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class FeatureImportance(BaseModel):
|
| 270 |
+
"""Feature importance results from explainability analysis"""
|
| 271 |
+
method: str
|
| 272 |
+
importance_scores: List[float]
|
| 273 |
+
top_features: TopFeatures
|
| 274 |
+
summary: FeatureImportanceSummary
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class ExplanationResult(BaseModel):
|
| 278 |
+
"""Result from an explainability analysis"""
|
| 279 |
+
prediction: int
|
| 280 |
+
confidence: float
|
| 281 |
+
probabilities: List[float]
|
| 282 |
+
class_labels: List[str]
|
| 283 |
+
model_used: str
|
| 284 |
+
spectrum_filename: Optional[str] = None
|
| 285 |
+
feature_importance: Optional[FeatureImportance] = None
|
| 286 |
+
|
| 287 |
+
class Config:
|
| 288 |
+
"""Pydantic model configuration"""
|
| 289 |
+
from_attributes = True
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class ModelInfo(BaseModel):
|
| 293 |
+
"""Model information and capabilities"""
|
| 294 |
+
|
| 295 |
+
name: str = Field(..., description="Model identifier")
|
| 296 |
+
description: str = Field(..., description="Model description")
|
| 297 |
+
input_length: int = Field(..., description="Expected input length")
|
| 298 |
+
num_classes: int = Field(..., description="Number of output classes")
|
| 299 |
+
supported_modalities: List[str] = Field(
|
| 300 |
+
default_factory=list, description="Supported modalities"
|
| 301 |
+
)
|
| 302 |
+
performance: Dict[str, float] = Field(
|
| 303 |
+
default_factory=dict, description="Performance metrics"
|
| 304 |
+
)
|
| 305 |
+
parameters: Optional[str] = Field(None, description="Parameter count")
|
| 306 |
+
speed: Optional[str] = Field(None, description="Relative speed category")
|
| 307 |
+
citation: Optional[str] = Field(None, description="Citation/reference")
|
| 308 |
+
available: bool = Field(...,
|
| 309 |
+
description="Whether model is available for inference")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class SystemHealth(BaseModel):
|
| 313 |
+
"""System health metrics"""
|
| 314 |
+
status: str = Field(..., description="Overall system status, e.g., 'ok'.")
|
| 315 |
+
timestamp: float = Field(...,
|
| 316 |
+
description="The server timestamp of the health check.")
|
| 317 |
+
models_loaded: int
|
| 318 |
+
total_models: int
|
| 319 |
+
memory_usage_mb: float
|
| 320 |
+
torch_version: str
|
| 321 |
+
cuda_available: bool
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class SystemInfo(BaseModel):
|
| 325 |
+
"""System information and health"""
|
| 326 |
+
|
| 327 |
+
version: str = Field(..., description="API version")
|
| 328 |
+
available_models: List[ModelInfo] = Field(
|
| 329 |
+
default_factory=list, description="Available models"
|
| 330 |
+
)
|
| 331 |
+
supported_modalities: List[str] = Field(
|
| 332 |
+
default_factory=list, description="Supported spectroscopy modalities"
|
| 333 |
+
)
|
| 334 |
+
max_batch_size: int = Field(100, ge=1, description="Maximum batch size")
|
| 335 |
+
target_spectrum_length: int = Field(
|
| 336 |
+
500, ge=1, description="Target spectrum length")
|
| 337 |
+
system_health: SystemHealth = Field(
|
| 338 |
+
..., description="System health metrics"
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class ErrorResponse(BaseModel):
|
| 343 |
+
"""Standardized error response"""
|
| 344 |
+
|
| 345 |
+
error: str = Field(..., description="Error message")
|
| 346 |
+
error_code: str = Field(...,
|
| 347 |
+
description="Error code for programmatic handling")
|
| 348 |
+
details: Optional[Dict[str, Any]] = Field(
|
| 349 |
+
None, description="Additional error details"
|
| 350 |
+
)
|
| 351 |
+
timestamp: str = Field(..., description="Error timestamp")
|
| 352 |
+
request_id: Optional[str] = Field(
|
| 353 |
+
None, description="Request ID for tracking")
|
backend/registry.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models/registry.py
|
| 2 |
+
from typing import Callable, Dict, List, Any
|
| 3 |
+
from models.figure2_cnn import Figure2CNN
|
| 4 |
+
from models.resnet_cnn import ResNet1D
|
| 5 |
+
from models.resnet18_vision import ResNet18Vision
|
| 6 |
+
from models.enhanced_cnn import EnhancedCNN, EfficientSpectralCNN, HybridSpectralNet
|
| 7 |
+
|
| 8 |
+
# Internal registry of model builders keyed by short name.
|
| 9 |
+
_REGISTRY: Dict[str, Callable[[int], object]] = {
|
| 10 |
+
"figure2": lambda L: Figure2CNN(input_length=L),
|
| 11 |
+
"resnet": lambda L: ResNet1D(input_length=L),
|
| 12 |
+
"resnet18vision": lambda L: ResNet18Vision(input_length=L),
|
| 13 |
+
"enhanced_cnn": lambda L: EnhancedCNN(input_length=L),
|
| 14 |
+
"efficient_cnn": lambda L: EfficientSpectralCNN(input_length=L),
|
| 15 |
+
"hybrid_net": lambda L: HybridSpectralNet(input_length=L),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
# Model specifications with metadata for enhanced features
|
| 19 |
+
_MODEL_SPECS: Dict[str, Dict[str, Any]] = {
|
| 20 |
+
"figure2": {
|
| 21 |
+
"input_length": 500,
|
| 22 |
+
"num_classes": 2,
|
| 23 |
+
"description": "Figure 2 baseline custom implementation",
|
| 24 |
+
"modalities": ["raman", "ftir"],
|
| 25 |
+
"citation": "Neo et al., 2023, Resour. Conserv. Recycl., 188, 106718",
|
| 26 |
+
"performance": {"accuracy": 0.948, "f1_score": 0.943},
|
| 27 |
+
"parameters": "~500K",
|
| 28 |
+
"speed": "fast",
|
| 29 |
+
},
|
| 30 |
+
"resnet": {
|
| 31 |
+
"input_length": 500,
|
| 32 |
+
"num_classes": 2,
|
| 33 |
+
"description": "(Residual Network) uses skip connections to train much deeper networks",
|
| 34 |
+
"modalities": ["raman", "ftir"],
|
| 35 |
+
"citation": "Custom ResNet implementation",
|
| 36 |
+
"performance": {"accuracy": 0.962, "f1_score": 0.959},
|
| 37 |
+
"parameters": "~100K",
|
| 38 |
+
"speed": "very_fast",
|
| 39 |
+
},
|
| 40 |
+
"resnet18vision": {
|
| 41 |
+
"input_length": 500,
|
| 42 |
+
"num_classes": 2,
|
| 43 |
+
"description": "excels at image recognition tasks by using 'residual blocks' to train more efficiently",
|
| 44 |
+
"modalities": ["raman", "ftir"],
|
| 45 |
+
"citation": "ResNet18 Vision adaptation",
|
| 46 |
+
"performance": {"accuracy": 0.945, "f1_score": 0.940},
|
| 47 |
+
"parameters": "~11M",
|
| 48 |
+
"speed": "medium",
|
| 49 |
+
},
|
| 50 |
+
"enhanced_cnn": {
|
| 51 |
+
"input_length": 500,
|
| 52 |
+
"num_classes": 2,
|
| 53 |
+
"description": "Enhanced CNN with attention mechanisms and multi-scale feature extraction",
|
| 54 |
+
"modalities": ["raman", "ftir"],
|
| 55 |
+
"citation": "Custom enhanced architecture with attention",
|
| 56 |
+
"performance": {"accuracy": 0.975, "f1_score": 0.973},
|
| 57 |
+
"parameters": "~800K",
|
| 58 |
+
"speed": "medium",
|
| 59 |
+
"features": ["attention", "multi_scale", "batch_norm", "dropout"],
|
| 60 |
+
},
|
| 61 |
+
"efficient_cnn": {
|
| 62 |
+
"input_length": 500,
|
| 63 |
+
"num_classes": 2,
|
| 64 |
+
"description": "Efficient CNN optimized for real-time inference with depthwise separable convolutions",
|
| 65 |
+
"modalities": ["raman", "ftir"],
|
| 66 |
+
"citation": "Custom efficient architecture",
|
| 67 |
+
"performance": {"accuracy": 0.955, "f1_score": 0.952},
|
| 68 |
+
"parameters": "~200K",
|
| 69 |
+
"speed": "very_fast",
|
| 70 |
+
"features": ["depthwise_separable", "lightweight", "real_time"],
|
| 71 |
+
},
|
| 72 |
+
"hybrid_net": {
|
| 73 |
+
"input_length": 500,
|
| 74 |
+
"num_classes": 2,
|
| 75 |
+
"description": "Hybrid network combining CNN backbone with self-attention mechanisms",
|
| 76 |
+
"modalities": ["raman", "ftir"],
|
| 77 |
+
"citation": "Custom hybrid CNN-Transformer architecture",
|
| 78 |
+
"performance": {"accuracy": 0.968, "f1_score": 0.965},
|
| 79 |
+
"parameters": "~1.2M",
|
| 80 |
+
"speed": "medium",
|
| 81 |
+
"features": ["self_attention", "cnn_backbone", "transformer_head"],
|
| 82 |
+
},
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Placeholder for future model expansions
|
| 86 |
+
_FUTURE_MODELS = {
|
| 87 |
+
"densenet1d": {
|
| 88 |
+
"description": "DenseNet1D for spectroscopy with dense connections",
|
| 89 |
+
"status": "planned",
|
| 90 |
+
"modalities": ["raman", "ftir"],
|
| 91 |
+
"features": ["dense_connections", "parameter_efficient"],
|
| 92 |
+
},
|
| 93 |
+
"ensemble_cnn": {
|
| 94 |
+
"description": "Ensemble of multiple CNN variants for robust predictions",
|
| 95 |
+
"status": "planned",
|
| 96 |
+
"modalities": ["raman", "ftir"],
|
| 97 |
+
"features": ["ensemble", "robust", "high_accuracy"],
|
| 98 |
+
},
|
| 99 |
+
"vision_transformer": {
|
| 100 |
+
"description": "Vision Transformer adapted for 1D spectral data",
|
| 101 |
+
"status": "planned",
|
| 102 |
+
"modalities": ["raman", "ftir"],
|
| 103 |
+
"features": ["transformer", "attention", "state_of_art"],
|
| 104 |
+
},
|
| 105 |
+
"autoencoder_cnn": {
|
| 106 |
+
"description": "CNN with autoencoder for unsupervised feature learning",
|
| 107 |
+
"status": "planned",
|
| 108 |
+
"modalities": ["raman", "ftir"],
|
| 109 |
+
"features": ["autoencoder", "unsupervised", "feature_learning"],
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def choices():
|
| 115 |
+
"""Return the list of available model keys."""
|
| 116 |
+
return list(_REGISTRY.keys())
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def planned_models():
|
| 120 |
+
"""Return the list of planned future model keys."""
|
| 121 |
+
return list(_FUTURE_MODELS.keys())
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def build(name: str, input_length: int):
|
| 125 |
+
"""Instantiate a model by short name with the given input length."""
|
| 126 |
+
if name not in _REGISTRY:
|
| 127 |
+
raise ValueError(f"Unknown model '{name}'. Choices: {choices()}")
|
| 128 |
+
return _REGISTRY[name](input_length)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def build_multiple(names: List[str], input_length: int) -> Dict[str, Any]:
|
| 132 |
+
"""Nuild multiple models for comparison."""
|
| 133 |
+
models = {}
|
| 134 |
+
for name in names:
|
| 135 |
+
if name in _REGISTRY:
|
| 136 |
+
models[name] = build(name, input_length)
|
| 137 |
+
else:
|
| 138 |
+
raise ValueError(f"Unknown model '{name}'. Available: {choices()}")
|
| 139 |
+
return models
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def register_model(
|
| 143 |
+
name: str, builder: Callable[[int], object], spec: Dict[str, Any]
|
| 144 |
+
) -> None:
|
| 145 |
+
"""Dynamically register a new model."""
|
| 146 |
+
if name in _REGISTRY:
|
| 147 |
+
raise ValueError(f"Model '{name}' already registered.")
|
| 148 |
+
if not callable(builder):
|
| 149 |
+
raise TypeError("Builder must be a callable that accepts an integer argument.")
|
| 150 |
+
_REGISTRY[name] = builder
|
| 151 |
+
_MODEL_SPECS[name] = spec
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def spec(name: str):
|
| 155 |
+
"""Return expected input length and number of classes for a model key."""
|
| 156 |
+
if name in _MODEL_SPECS:
|
| 157 |
+
return _MODEL_SPECS[name].copy()
|
| 158 |
+
raise KeyError(f"Unknown model '{name}'. Available: {choices()}")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_model_info(name: str) -> Dict[str, Any]:
|
| 162 |
+
"""Get comprehensive model information including metadata."""
|
| 163 |
+
if name in _MODEL_SPECS:
|
| 164 |
+
return _MODEL_SPECS[name].copy()
|
| 165 |
+
elif name in _FUTURE_MODELS:
|
| 166 |
+
return _FUTURE_MODELS[name].copy()
|
| 167 |
+
else:
|
| 168 |
+
raise KeyError(f"Unknown model '{name}'")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def models_for_modality(modality: str) -> List[str]:
|
| 172 |
+
"""Get list of models that support a specific modality."""
|
| 173 |
+
compatible = []
|
| 174 |
+
for name, spec_info in _MODEL_SPECS.items():
|
| 175 |
+
if modality in spec_info.get("modalities", []):
|
| 176 |
+
compatible.append(name)
|
| 177 |
+
return compatible
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def validate_model_list(names: List[str]) -> List[str]:
|
| 181 |
+
"""Validate and return list of available models from input list."""
|
| 182 |
+
available = choices()
|
| 183 |
+
valid_models = []
|
| 184 |
+
for name in names:
|
| 185 |
+
if name in available: # Fixed: was using 'is' instead of 'in'
|
| 186 |
+
valid_models.append(name)
|
| 187 |
+
return valid_models
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def get_models_metadata() -> Dict[str, Dict[str, Any]]:
|
| 191 |
+
"""Get metadata for all registered models."""
|
| 192 |
+
return {name: _MODEL_SPECS[name].copy() for name in _MODEL_SPECS}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def is_model_compatible(name: str, modality: str) -> bool:
|
| 196 |
+
"""Check if a model is compatible with a specific modality."""
|
| 197 |
+
if name not in _MODEL_SPECS:
|
| 198 |
+
return False
|
| 199 |
+
return modality in _MODEL_SPECS[name].get("modalities", [])
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_model_capabilities(name: str) -> Dict[str, Any]:
|
| 203 |
+
"""Get detailed capabilities of a model."""
|
| 204 |
+
if name not in _MODEL_SPECS:
|
| 205 |
+
raise KeyError(f"Unknown model '{name}'")
|
| 206 |
+
|
| 207 |
+
spec = _MODEL_SPECS[name].copy()
|
| 208 |
+
spec.update(
|
| 209 |
+
{
|
| 210 |
+
"available": True,
|
| 211 |
+
"status": "active",
|
| 212 |
+
"supported_tasks": ["binary_classification"],
|
| 213 |
+
"performance_metrics": {
|
| 214 |
+
"supports_confidence": True,
|
| 215 |
+
"supports_batch": True,
|
| 216 |
+
"memory_efficient": spec.get("description", "").lower().find("resnet")
|
| 217 |
+
!= -1,
|
| 218 |
+
},
|
| 219 |
+
}
|
| 220 |
+
)
|
| 221 |
+
return spec
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
__all__ = [
|
| 225 |
+
"choices",
|
| 226 |
+
"build",
|
| 227 |
+
"spec",
|
| 228 |
+
"build_multiple",
|
| 229 |
+
"register_model",
|
| 230 |
+
"get_model_info",
|
| 231 |
+
"models_for_modality",
|
| 232 |
+
"validate_model_list",
|
| 233 |
+
"planned_models",
|
| 234 |
+
"get_models_metadata",
|
| 235 |
+
"is_model_compatible",
|
| 236 |
+
"get_model_capabilities",
|
| 237 |
+
]
|
backend/service.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: wrong-import-order, unused-import, import-outside-toplevel
|
| 2 |
+
"""
|
| 3 |
+
Backend service layer for ML inference.
|
| 4 |
+
Extracts and preserves the current Streamlit application logic for FastAPI.
|
| 5 |
+
Maintains scientific fidelity and deterministic outputs.
|
| 6 |
+
"""
|
| 7 |
+
import time
|
| 8 |
+
import gc
|
| 9 |
+
from typing import Tuple, List
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import psutil
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from backend.utils.preprocessing import (
|
| 18 |
+
# We will replace these with SpectrumPreprocessor
|
| 19 |
+
# remove_baseline, smooth_spectrum, normalize_spectrum,
|
| 20 |
+
validate_spectrum_modality,
|
| 21 |
+
MODALITY_PARAMS
|
| 22 |
+
)
|
| 23 |
+
from backend.models.registry import get_model_info as get_registry_model_info
|
| 24 |
+
from backend.utils.performance import log_model_performance
|
| 25 |
+
from .config import TARGET_LEN, LABEL_MAP
|
| 26 |
+
|
| 27 |
+
from .pydantic_models import (
|
| 28 |
+
SpectrumData,
|
| 29 |
+
PredictionResult,
|
| 30 |
+
PreprocessingMetadata,
|
| 31 |
+
QualityControlMetadata,
|
| 32 |
+
ModelMetadata,
|
| 33 |
+
ModelInfo,
|
| 34 |
+
SystemInfo,
|
| 35 |
+
SystemHealth
|
| 36 |
+
)
|
| 37 |
+
from backend.utils.model_manager import model_manager
|
| 38 |
+
from backend.utils.preprocessing_fixed import SpectrumPreprocessor
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class MLServiceError(Exception):
|
| 42 |
+
"""Custom exception for ML service errors."""
|
| 43 |
+
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class MLInferenceService:
|
| 48 |
+
"""
|
| 49 |
+
Core ML inference service that preserves the exact behavior of the Streamlit app.
|
| 50 |
+
Maintains scientific fidelity and deterministic outputs.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, model_manager_instance=model_manager):
|
| 54 |
+
self.model_manager = model_manager_instance
|
| 55 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 56 |
+
|
| 57 |
+
def get_memory_usage(self) -> float:
|
| 58 |
+
"""Get current memory usage in MB"""
|
| 59 |
+
try:
|
| 60 |
+
process = psutil.Process()
|
| 61 |
+
return process.memory_info().rss / 1024 / 1024
|
| 62 |
+
except ImportError:
|
| 63 |
+
return 0.0
|
| 64 |
+
|
| 65 |
+
def cleanup_memory(self):
|
| 66 |
+
"""Clean up memory after inference"""
|
| 67 |
+
gc.collect()
|
| 68 |
+
if torch.cuda.is_available():
|
| 69 |
+
torch.cuda.empty_cache()
|
| 70 |
+
|
| 71 |
+
def create_preprocessing_metadata(
|
| 72 |
+
self,
|
| 73 |
+
modality: str,
|
| 74 |
+
original_length: int,
|
| 75 |
+
x_data: np.ndarray,
|
| 76 |
+
validation_result: Tuple[bool, List[str]]
|
| 77 |
+
) -> PreprocessingMetadata:
|
| 78 |
+
"""Create preprocessing provenance metadata"""
|
| 79 |
+
params = MODALITY_PARAMS.get(modality, MODALITY_PARAMS["raman"])
|
| 80 |
+
is_valid, issues = validation_result
|
| 81 |
+
|
| 82 |
+
return PreprocessingMetadata(
|
| 83 |
+
target_length=TARGET_LEN,
|
| 84 |
+
baseline_degree=params["baseline_degree"],
|
| 85 |
+
smooth_window=params["smooth_window"],
|
| 86 |
+
smooth_polyorder=params["smooth_polyorder"],
|
| 87 |
+
normalization_method="min_max",
|
| 88 |
+
modality_validated=is_valid,
|
| 89 |
+
validation_issues=issues,
|
| 90 |
+
original_length=original_length,
|
| 91 |
+
wavenumber_range=[float(np.min(x_data)), float(np.max(x_data))]
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def create_quality_control_metadata(
|
| 95 |
+
self,
|
| 96 |
+
y_data: np.ndarray,
|
| 97 |
+
y_processed: np.ndarray
|
| 98 |
+
) -> QualityControlMetadata:
|
| 99 |
+
"""Create quality control metadata with basic checks"""
|
| 100 |
+
issues = []
|
| 101 |
+
|
| 102 |
+
# Basic signal quality checks
|
| 103 |
+
signal_range = np.max(y_data) - np.min(y_data)
|
| 104 |
+
noise_estimate = np.std(np.diff(y_data))
|
| 105 |
+
snr = signal_range / noise_estimate if noise_estimate > 0 else None
|
| 106 |
+
|
| 107 |
+
# Check for saturation (values at extremes)
|
| 108 |
+
if np.any(y_data >= 0.99 * np.max(y_data)):
|
| 109 |
+
issues.append("Potential signal saturation detected")
|
| 110 |
+
|
| 111 |
+
# Check for cosmic rays (sudden spikes)
|
| 112 |
+
diff = np.abs(np.diff(y_data))
|
| 113 |
+
if len(diff) > 0:
|
| 114 |
+
threshold = np.mean(diff) + 5 * np.std(diff)
|
| 115 |
+
cosmic_ray_detected = np.any(diff > threshold)
|
| 116 |
+
if cosmic_ray_detected:
|
| 117 |
+
issues.append("Potential cosmic ray spikes detected")
|
| 118 |
+
else:
|
| 119 |
+
cosmic_ray_detected = False
|
| 120 |
+
|
| 121 |
+
# Baseline stability
|
| 122 |
+
baseline_stability = 0.0
|
| 123 |
+
if len(y_processed) >= 100:
|
| 124 |
+
baseline_stability = 1.0 - \
|
| 125 |
+
(np.std(y_processed[:50]) + np.std(y_processed[-50:])) / 2
|
| 126 |
+
baseline_stability = max(0.0, min(1.0, float(baseline_stability)))
|
| 127 |
+
|
| 128 |
+
return QualityControlMetadata(
|
| 129 |
+
signal_to_noise_ratio=snr,
|
| 130 |
+
baseline_stability=baseline_stability if baseline_stability > 0 else None,
|
| 131 |
+
spectral_resolution=None,
|
| 132 |
+
cosmic_ray_detected=bool(cosmic_ray_detected),
|
| 133 |
+
saturation_detected=any("saturation" in issue.lower()
|
| 134 |
+
for issue in issues),
|
| 135 |
+
issues=issues
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
def create_model_metadata(
|
| 139 |
+
self,
|
| 140 |
+
model_name: str,
|
| 141 |
+
weights_loaded: bool,
|
| 142 |
+
weights_path: Path
|
| 143 |
+
) -> ModelMetadata:
|
| 144 |
+
"""Create model metadata with calibration details"""
|
| 145 |
+
info = get_registry_model_info(model_name)
|
| 146 |
+
|
| 147 |
+
return ModelMetadata(
|
| 148 |
+
model_name=model_name,
|
| 149 |
+
model_description=info.get("description", ""),
|
| 150 |
+
model_version=None,
|
| 151 |
+
training_date=None,
|
| 152 |
+
input_length=info.get("input_length", TARGET_LEN),
|
| 153 |
+
num_classes=info.get("num_classes", 2),
|
| 154 |
+
parameters_count=info.get("parameters", "Unknown"),
|
| 155 |
+
performance_metrics=info.get("performance", {}),
|
| 156 |
+
supported_modalities=info.get("modalities", ["raman", "ftir"]),
|
| 157 |
+
citation=info.get("citation", ""),
|
| 158 |
+
weights_loaded=weights_loaded, # This comes from model_manager
|
| 159 |
+
weights_path=str(weights_path) if weights_loaded else None
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def run_inference(
|
| 163 |
+
self,
|
| 164 |
+
spectrum: SpectrumData,
|
| 165 |
+
model_name: str,
|
| 166 |
+
modality: str,
|
| 167 |
+
include_provenance: bool = True
|
| 168 |
+
) -> PredictionResult:
|
| 169 |
+
"""
|
| 170 |
+
Run model inference preserving exact Streamlit behavior.
|
| 171 |
+
Returns complete result with full provenance metadata.
|
| 172 |
+
"""
|
| 173 |
+
start_total = time.time()
|
| 174 |
+
start_memory = self.get_memory_usage()
|
| 175 |
+
|
| 176 |
+
# Convert input data
|
| 177 |
+
x_data = np.array(spectrum.x_values)
|
| 178 |
+
y_data = np.array(spectrum.y_values)
|
| 179 |
+
original_length = len(y_data)
|
| 180 |
+
|
| 181 |
+
if original_length < 2:
|
| 182 |
+
raise MLServiceError("Spectrum must have at least 2 data points")
|
| 183 |
+
|
| 184 |
+
# Validate modality
|
| 185 |
+
validation_result = validate_spectrum_modality(x_data, y_data, modality)
|
| 186 |
+
|
| 187 |
+
# Preprocessing
|
| 188 |
+
start_preprocess = time.time()
|
| 189 |
+
# Use SpectrumPreprocessor for consistent preprocessing
|
| 190 |
+
preprocessor = SpectrumPreprocessor(
|
| 191 |
+
target_len=TARGET_LEN,
|
| 192 |
+
do_baseline=True, # Assuming these are desired for standard analysis
|
| 193 |
+
do_smooth=True,
|
| 194 |
+
do_normalize=True,
|
| 195 |
+
modality=modality
|
| 196 |
+
)
|
| 197 |
+
y_processed = preprocessor.preprocess_single_spectrum(x_data, y_data, use_fitted_stats=False)
|
| 198 |
+
# For x_resampled, we can just generate it based on target_len and original range
|
| 199 |
+
x_resampled = np.linspace(np.min(x_data), np.max(x_data), TARGET_LEN)
|
| 200 |
+
|
| 201 |
+
preprocessing_time = time.time() - start_preprocess
|
| 202 |
+
|
| 203 |
+
# Load model
|
| 204 |
+
model, weights_loaded, weights_path = self.model_manager.load_model(model_name)
|
| 205 |
+
if model is None:
|
| 206 |
+
raise MLServiceError(f"Model '{model_name}' not available")
|
| 207 |
+
|
| 208 |
+
# Ensure model is on the correct device before inference
|
| 209 |
+
model.to(self.device)
|
| 210 |
+
|
| 211 |
+
# Create input tensor
|
| 212 |
+
input_tensor = torch.tensor(y_processed, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
|
| 213 |
+
input_tensor = input_tensor.to(self.device) # Move to device
|
| 214 |
+
# Inference
|
| 215 |
+
start_inference = time.time()
|
| 216 |
+
model.eval()
|
| 217 |
+
with torch.no_grad():
|
| 218 |
+
logits = model(input_tensor)
|
| 219 |
+
prediction = torch.argmax(logits, dim=1).item()
|
| 220 |
+
logits_list = logits.detach().numpy().tolist()[0]
|
| 221 |
+
probs = F.softmax(logits.detach(), dim=1).cpu().numpy().flatten()
|
| 222 |
+
|
| 223 |
+
inference_time = time.time() - start_inference
|
| 224 |
+
total_time = time.time() - start_total
|
| 225 |
+
end_memory = self.get_memory_usage()
|
| 226 |
+
memory_usage = max(end_memory - start_memory, 0)
|
| 227 |
+
|
| 228 |
+
# Log performance metrics for benchmarking
|
| 229 |
+
log_model_performance(
|
| 230 |
+
model_name=model_name,
|
| 231 |
+
inference_time=inference_time,
|
| 232 |
+
preprocessing_time=preprocessing_time,
|
| 233 |
+
total_time=total_time,
|
| 234 |
+
memory_usage=memory_usage,
|
| 235 |
+
spectrum_length=original_length
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Calculate confidence
|
| 239 |
+
confidence = float(max(probs)) if probs is not None and len(
|
| 240 |
+
probs) > 0 else 0.0
|
| 241 |
+
|
| 242 |
+
# Create metadata
|
| 243 |
+
if include_provenance:
|
| 244 |
+
preprocessing_metadata = self.create_preprocessing_metadata(
|
| 245 |
+
modality, original_length, x_data, validation_result
|
| 246 |
+
)
|
| 247 |
+
qc_metadata = self.create_quality_control_metadata(
|
| 248 |
+
y_data, y_processed)
|
| 249 |
+
model_metadata = self.create_model_metadata(
|
| 250 |
+
model_name, weights_loaded, weights_path)
|
| 251 |
+
else:
|
| 252 |
+
preprocessing_metadata = PreprocessingMetadata(
|
| 253 |
+
target_length=TARGET_LEN,
|
| 254 |
+
baseline_degree=2,
|
| 255 |
+
smooth_window=11,
|
| 256 |
+
smooth_polyorder=2,
|
| 257 |
+
normalization_method="min_max",
|
| 258 |
+
modality_validated=validation_result[0],
|
| 259 |
+
validation_issues=validation_result[1],
|
| 260 |
+
original_length=original_length,
|
| 261 |
+
wavenumber_range=[float(np.min(x_data)), float(np.max(x_data))]
|
| 262 |
+
)
|
| 263 |
+
qc_metadata = QualityControlMetadata(
|
| 264 |
+
signal_to_noise_ratio=None,
|
| 265 |
+
baseline_stability=None,
|
| 266 |
+
spectral_resolution=None,
|
| 267 |
+
cosmic_ray_detected=False,
|
| 268 |
+
saturation_detected=False,
|
| 269 |
+
issues=[]
|
| 270 |
+
)
|
| 271 |
+
model_metadata = self.create_model_metadata(
|
| 272 |
+
model_name, weights_loaded, weights_path) # Still need model metadata
|
| 273 |
+
|
| 274 |
+
# Create processed spectrum data
|
| 275 |
+
processed_spectrum = SpectrumData(
|
| 276 |
+
x_values=x_resampled.tolist(),
|
| 277 |
+
y_values=y_processed.tolist(),
|
| 278 |
+
filename=f"processed_{spectrum.filename}" if spectrum.filename else None
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
# Clean up memory
|
| 282 |
+
self.cleanup_memory()
|
| 283 |
+
|
| 284 |
+
return PredictionResult(
|
| 285 |
+
prediction=prediction,
|
| 286 |
+
prediction_label=LABEL_MAP[prediction] if prediction in LABEL_MAP else "Unknown",
|
| 287 |
+
confidence=confidence,
|
| 288 |
+
probabilities=probs.tolist(),
|
| 289 |
+
logits=logits_list,
|
| 290 |
+
preprocessing=preprocessing_metadata,
|
| 291 |
+
quality_control=qc_metadata,
|
| 292 |
+
model_metadata=model_metadata,
|
| 293 |
+
inference_time=inference_time,
|
| 294 |
+
preprocessing_time=preprocessing_time,
|
| 295 |
+
total_time=total_time,
|
| 296 |
+
memory_usage_mb=memory_usage,
|
| 297 |
+
original_spectrum=spectrum,
|
| 298 |
+
processed_spectrum=processed_spectrum,
|
| 299 |
+
timestamp=datetime.now().isoformat()
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
def get_available_models(self) -> List[ModelInfo]:
|
| 303 |
+
"""Get list of available models with their information"""
|
| 304 |
+
return self.model_manager.get_available_models()
|
| 305 |
+
|
| 306 |
+
def get_system_info(self) -> SystemInfo:
|
| 307 |
+
"""Get system information and health status"""
|
| 308 |
+
models = self.model_manager.get_available_models()
|
| 309 |
+
|
| 310 |
+
system_health_data = SystemHealth(
|
| 311 |
+
status="ok",
|
| 312 |
+
timestamp=time.time(),
|
| 313 |
+
models_loaded=sum(1 for m in models if m.available),
|
| 314 |
+
total_models=len(models),
|
| 315 |
+
memory_usage_mb=self.get_memory_usage(),
|
| 316 |
+
torch_version=torch.__version__,
|
| 317 |
+
cuda_available=torch.cuda.is_available()
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
return SystemInfo(
|
| 321 |
+
version="1.0.0",
|
| 322 |
+
available_models=models,
|
| 323 |
+
supported_modalities=["raman", "ftir"],
|
| 324 |
+
max_batch_size=100,
|
| 325 |
+
target_spectrum_length=TARGET_LEN,
|
| 326 |
+
system_health=system_health_data
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# Global service instance
|
| 331 |
+
ml_service = MLInferenceService()
|
backend/service.py # (edit
ADDED
|
File without changes
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from backend.main import app
|
| 3 |
+
|
| 4 |
+
client = TestClient(app)
|
| 5 |
+
|
| 6 |
+
def test_analyze_spectrum():
|
| 7 |
+
payload = {
|
| 8 |
+
"spectrum": {
|
| 9 |
+
"x_values": [200, 210], # At least 2 points
|
| 10 |
+
"y_values": [0.5, 0.6], # At least 2 points
|
| 11 |
+
"filename": "test.txt"
|
| 12 |
+
},
|
| 13 |
+
"modality": "raman",
|
| 14 |
+
"model_name": "figure2"
|
| 15 |
+
}
|
| 16 |
+
response = client.post("/api/v1/analyze", json=payload)
|
| 17 |
+
assert response.status_code == 200
|
backend/tests/test_service.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for backend.service.run_inference and /api/v1/analyze endpoint.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import unittest
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
from backend.service import ml_service
|
| 8 |
+
from backend.pydantic_models import SpectrumData # Adjust import if needed
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
from backend.main import app
|
| 11 |
+
|
| 12 |
+
class TestService(unittest.TestCase):
|
| 13 |
+
"""Tests for ml_service.run_inference."""
|
| 14 |
+
|
| 15 |
+
@patch('backend.service.log_model_performance')
|
| 16 |
+
def test_run_inference_calls_log_model_performance(self, mock_log_model_performance):
|
| 17 |
+
"""Test that run_inference calls log_model_performance with valid input."""
|
| 18 |
+
# Build a real SpectrumData instance with required fields only
|
| 19 |
+
dummy_spectrum = SpectrumData(
|
| 20 |
+
x_values=[200, 210],
|
| 21 |
+
y_values=[0.5, 0.6],
|
| 22 |
+
filename="dummy.txt"
|
| 23 |
+
)
|
| 24 |
+
model_name = "figure2"
|
| 25 |
+
modality = "raman"
|
| 26 |
+
|
| 27 |
+
# Call with separate model_name and modality args (not as SpectrumData attributes)
|
| 28 |
+
ml_service.run_inference(dummy_spectrum, model_name, modality)
|
| 29 |
+
|
| 30 |
+
mock_log_model_performance.assert_called_once()
|
| 31 |
+
|
| 32 |
+
class TestAPI(unittest.TestCase):
|
| 33 |
+
"""Tests for /api/v1/analyze endpoint."""
|
| 34 |
+
|
| 35 |
+
def setUp(self):
|
| 36 |
+
self.client = TestClient(app)
|
| 37 |
+
|
| 38 |
+
def test_analyze_spectrum_valid_payload(self):
|
| 39 |
+
"""Test /api/v1/analyze with valid payload."""
|
| 40 |
+
payload = {
|
| 41 |
+
"spectrum": {
|
| 42 |
+
"x_values": [200, 210],
|
| 43 |
+
"y_values": [0.5, 0.6],
|
| 44 |
+
"filename": "dummy.txt"
|
| 45 |
+
},
|
| 46 |
+
"modality": "raman",
|
| 47 |
+
"model_name": "figure2"
|
| 48 |
+
}
|
| 49 |
+
response = self.client.post("/api/v1/analyze", json=payload)
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
# Optionally, check response.json() for expected keys
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
unittest.main()
|
backend/utils/confidence.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Confidence calculation and visualization utilities.
|
| 2 |
+
Provides normalized softmax confidence and color-coded badges"""
|
| 3 |
+
|
| 4 |
+
from typing import Tuple, List
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def calculate_softmax_confidence(
|
| 11 |
+
logits: torch.Tensor,
|
| 12 |
+
) -> Tuple[np.ndarray, float, str, str]:
|
| 13 |
+
"""Calculate normalized confidence using softmax
|
| 14 |
+
Args:
|
| 15 |
+
logits: Raw model logits tensor
|
| 16 |
+
Returns:
|
| 17 |
+
Tuple of (probabilities, max_confidence, confidence_level, confidence_emoji)
|
| 18 |
+
"""
|
| 19 |
+
# ===Apply softmax to get probabilities===
|
| 20 |
+
probs_np = F.softmax(logits, dim=1).cpu().numpy().flatten()
|
| 21 |
+
|
| 22 |
+
# ===Get maximum probability as confidence===
|
| 23 |
+
max_confidence = float(np.max(probs_np))
|
| 24 |
+
|
| 25 |
+
# ===Determine confidence level and emoji===
|
| 26 |
+
if max_confidence >= 0.80:
|
| 27 |
+
confidence_level = "HIGH"
|
| 28 |
+
confidence_emoji = "🟢"
|
| 29 |
+
elif max_confidence >= 0.60:
|
| 30 |
+
confidence_level = "MEDIUM"
|
| 31 |
+
confidence_emoji = "🟡"
|
| 32 |
+
else:
|
| 33 |
+
confidence_level = "LOW"
|
| 34 |
+
confidence_emoji = "🔴"
|
| 35 |
+
|
| 36 |
+
return probs_np, max_confidence, confidence_level, confidence_emoji
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_confidence_badge(confidence: float) -> Tuple[str, str]:
|
| 40 |
+
"""Get confidence badge emoji and level description
|
| 41 |
+
Args:
|
| 42 |
+
confidence: Confidence value (0-1)
|
| 43 |
+
Returns:
|
| 44 |
+
Tuple of (emoji, level)
|
| 45 |
+
"""
|
| 46 |
+
if confidence >= 0.80:
|
| 47 |
+
return "🟢", "HIGH"
|
| 48 |
+
elif confidence >= 0.60:
|
| 49 |
+
return "🟡", "MEDIUM"
|
| 50 |
+
else:
|
| 51 |
+
return "🔴", "LOW"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def format_confidence_display(confidence: float, level: str, emoji: str) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Format confidence for display in UI
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
confidence: Confidence value (0-1)
|
| 60 |
+
level: Confidence level (HIGH/MEDIUM/LOW)
|
| 61 |
+
emoji: Confidence emoji
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Formatted confidence string
|
| 65 |
+
"""
|
| 66 |
+
return f"{emoji} **{level}** ({confidence:.1%})"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def calculate_legacy_confidence(logits_list: List[float]) -> Tuple[float, str, str]:
|
| 70 |
+
"""
|
| 71 |
+
Calculate confidence using legacy logit margin method for backward compatibility
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
logits_list: List of raw logits
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
Tuple of (margin, confidence_level, confidence_emoji)
|
| 78 |
+
"""
|
| 79 |
+
if len(logits_list) < 2:
|
| 80 |
+
return 0.0, "LOW", "🔴"
|
| 81 |
+
|
| 82 |
+
logits_array = np.array(logits_list)
|
| 83 |
+
sorted_logits = np.sort(logits_array)[::-1] # Descending order
|
| 84 |
+
margin = sorted_logits[0] - sorted_logits[1]
|
| 85 |
+
|
| 86 |
+
# ===Define thresholds for margin-based confidence===
|
| 87 |
+
if margin >= 2.0:
|
| 88 |
+
confidence_level = "HIGH"
|
| 89 |
+
confidence_emoji = "🟢"
|
| 90 |
+
elif margin >= 1.0:
|
| 91 |
+
confidence_level = "MEDIUM"
|
| 92 |
+
confidence_emoji = "🟡"
|
| 93 |
+
else:
|
| 94 |
+
confidence_level = "LOW"
|
| 95 |
+
confidence_emoji = "🔴"
|
| 96 |
+
|
| 97 |
+
return margin, confidence_level, confidence_emoji
|
backend/utils/enhanced_ml_service.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=wrong-import-order, unused-import
|
| 2 |
+
"""
|
| 3 |
+
Enhanced API endpoints with explainability features.
|
| 4 |
+
Extends the existing FastAPI backend with SHAP-based model explanations
|
| 5 |
+
and improved prediction capabilities.
|
| 6 |
+
"""
|
| 7 |
+
from backend.config import TARGET_LEN # Import TARGET_LEN for model loading
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
from typing import Dict, Any, List, Optional
|
| 11 |
+
from fastapi import HTTPException # Keep HTTPException for API errors
|
| 12 |
+
# PredictionResult is not directly returned by this service
|
| 13 |
+
from backend.pydantic_models import SpectrumData
|
| 14 |
+
from backend.models.registry import build as build_model, choices, registry_spec
|
| 15 |
+
from backend.utils.preprocessing_fixed import SpectrumPreprocessor
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
# Import moved here to the toplevel
|
| 19 |
+
from backend.utils.model_manager import model_manager
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class EnhancedMLService:
|
| 23 |
+
"""
|
| 24 |
+
Enhanced ML service with explainability features.
|
| 25 |
+
Provides predictions with feature importance and model confidence.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self.model_manager = model_manager
|
| 30 |
+
# Local cache for loaded models (model, preprocessor)
|
| 31 |
+
self._model_cache = {}
|
| 32 |
+
self.device = torch.device(
|
| 33 |
+
"cuda" if torch.cuda.is_available() else "cpu")
|
| 34 |
+
print(f"✅ Enhanced ML Service initialized on {self.device}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def cache_model(self, model_name: str, model_instance, preprocessor):
|
| 38 |
+
"""Public method to cache a model and its preprocessor."""
|
| 39 |
+
self._model_cache[model_name] = {
|
| 40 |
+
'model': model_instance,
|
| 41 |
+
'preprocessor': preprocessor
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
def predict_with_explanation(
|
| 45 |
+
self,
|
| 46 |
+
spectrum_data: SpectrumData,
|
| 47 |
+
model_name: str,
|
| 48 |
+
modality: str = "raman",
|
| 49 |
+
include_feature_importance: bool = True
|
| 50 |
+
) -> Dict[str, Any]:
|
| 51 |
+
"""
|
| 52 |
+
Make prediction with explainability features.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
spectrum_data (SpectrumData): Input spectrum data
|
| 56 |
+
model_name (str): Name of model to use
|
| 57 |
+
modality (str): The spectroscopy modality ('raman' or 'ftir')
|
| 58 |
+
include_feature_importance (bool): Whether to compute feature importance
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
dict: Prediction results with explanations
|
| 62 |
+
"""
|
| 63 |
+
if model_name not in self._model_cache:
|
| 64 |
+
# Attempt to load model via centralized manager if not in local cache
|
| 65 |
+
model_instance, weights_loaded, _ = self.model_manager.load_model(
|
| 66 |
+
model_name)
|
| 67 |
+
if model_instance is None or not weights_loaded:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=400,
|
| 70 |
+
detail=f"Model {model_name} not loaded or weights not found"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Determine model input length robustly: prefer model attribute,
|
| 74 |
+
# fallback to registry/spec, then TARGET_LEN
|
| 75 |
+
input_len = getattr(model_instance, 'input_length', None)
|
| 76 |
+
if input_len is None:
|
| 77 |
+
try:
|
| 78 |
+
spec_info = registry_spec(model_name)
|
| 79 |
+
input_len = int(spec_info.get("input_length", TARGET_LEN))
|
| 80 |
+
except Exception:
|
| 81 |
+
input_len = TARGET_LEN
|
| 82 |
+
|
| 83 |
+
# Create preprocessor for this model (use resolved input_len)
|
| 84 |
+
preprocessor = SpectrumPreprocessor(
|
| 85 |
+
target_len=input_len,
|
| 86 |
+
do_baseline=True,
|
| 87 |
+
do_smooth=True,
|
| 88 |
+
do_normalize=True,
|
| 89 |
+
modality=modality # Use the provided modality
|
| 90 |
+
)
|
| 91 |
+
self._model_cache[model_name] = {
|
| 92 |
+
'model': model_instance, 'preprocessor': preprocessor}
|
| 93 |
+
|
| 94 |
+
model_entry = self._model_cache.get(model_name)
|
| 95 |
+
if not model_entry: # Should not happen if previous block executed
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=400,
|
| 98 |
+
detail=f"Model {model_name} not loaded"
|
| 99 |
+
)
|
| 100 |
+
model = model_entry['model']
|
| 101 |
+
|
| 102 |
+
# --- FIX: Ensure preprocessor has the correct modality ---
|
| 103 |
+
# The preprocessor might have been cached with a default or different modality.
|
| 104 |
+
# We must ensure it matches the one from the current request.
|
| 105 |
+
if model_entry['preprocessor'].modality != modality:
|
| 106 |
+
print(
|
| 107 |
+
f"🔄 Updating preprocessor modality for '{model_name}' from '{model_entry['preprocessor'].modality}' to '{modality}'")
|
| 108 |
+
model_entry['preprocessor'] = SpectrumPreprocessor(
|
| 109 |
+
target_len=model.input_length,
|
| 110 |
+
do_baseline=True, do_smooth=True, do_normalize=True,
|
| 111 |
+
modality=modality
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
preprocessor = model_entry['preprocessor']
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Preprocess input data
|
| 118 |
+
x_data = np.array(spectrum_data.x_values)
|
| 119 |
+
y_data = np.array(spectrum_data.y_values)
|
| 120 |
+
|
| 121 |
+
# Preprocess spectrum
|
| 122 |
+
processed_spectrum = preprocessor.preprocess_single_spectrum(
|
| 123 |
+
x_data, y_data, use_fitted_stats=False
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Convert to tensor
|
| 127 |
+
input_tensor = torch.tensor(
|
| 128 |
+
processed_spectrum, dtype=torch.float32)
|
| 129 |
+
# Add batch and channel dimensions
|
| 130 |
+
input_tensor = input_tensor.unsqueeze(0)
|
| 131 |
+
input_tensor = input_tensor.unsqueeze(0)
|
| 132 |
+
input_tensor = input_tensor.to(self.device)
|
| 133 |
+
|
| 134 |
+
# Make prediction
|
| 135 |
+
with torch.no_grad():
|
| 136 |
+
outputs = model(input_tensor)
|
| 137 |
+
probabilities = torch.softmax(outputs, dim=1)
|
| 138 |
+
predicted_class = torch.argmax(probabilities, dim=1).item()
|
| 139 |
+
confidence = torch.max(probabilities).item()
|
| 140 |
+
|
| 141 |
+
# Basic prediction result
|
| 142 |
+
result = {
|
| 143 |
+
'prediction': predicted_class,
|
| 144 |
+
'confidence': confidence,
|
| 145 |
+
'probabilities': probabilities.cpu().numpy().tolist()[0],
|
| 146 |
+
'class_labels': ['stable', 'weathered'],
|
| 147 |
+
'model_used': model_name,
|
| 148 |
+
'spectrum_filename': spectrum_data.filename
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
# Add feature importance if requested
|
| 152 |
+
if include_feature_importance:
|
| 153 |
+
feature_importance = self._compute_feature_importance(
|
| 154 |
+
model, input_tensor, processed_spectrum
|
| 155 |
+
)
|
| 156 |
+
result['feature_importance'] = feature_importance
|
| 157 |
+
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
+
except (RuntimeError, ValueError, TypeError) as e:
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=500,
|
| 163 |
+
detail=f"Prediction failed: {str(e)}"
|
| 164 |
+
) from e
|
| 165 |
+
|
| 166 |
+
def _compute_feature_importance(
|
| 167 |
+
self,
|
| 168 |
+
model: torch.nn.Module,
|
| 169 |
+
input_tensor: torch.Tensor,
|
| 170 |
+
processed_spectrum: np.ndarray
|
| 171 |
+
) -> Dict[str, Any]:
|
| 172 |
+
"""
|
| 173 |
+
Compute feature importance using gradient-based methods.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
model: PyTorch model
|
| 177 |
+
input_tensor: Preprocessed input tensor
|
| 178 |
+
processed_spectrum: Original processed spectrum
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
dict: Feature importance information
|
| 182 |
+
"""
|
| 183 |
+
try:
|
| 184 |
+
# Enable gradient computation
|
| 185 |
+
input_tensor.requires_grad_(True)
|
| 186 |
+
torch.set_grad_enabled(True)
|
| 187 |
+
|
| 188 |
+
# Forward pass
|
| 189 |
+
output = model(input_tensor)
|
| 190 |
+
predicted_class = torch.argmax(output, dim=1).item()
|
| 191 |
+
|
| 192 |
+
# Compute gradients with respect to input
|
| 193 |
+
class_score = output[0, predicted_class]
|
| 194 |
+
class_score.backward()
|
| 195 |
+
|
| 196 |
+
if input_tensor.grad is not None:
|
| 197 |
+
gradients = input_tensor.grad.data.cpu().numpy().squeeze()
|
| 198 |
+
else:
|
| 199 |
+
raise RuntimeError(
|
| 200 |
+
"Gradients were not computed. Ensure requires_grad is set "
|
| 201 |
+
"and gradient computation is enabled."
|
| 202 |
+
)
|
| 203 |
+
gradients = input_tensor.grad.data.cpu().numpy().squeeze()
|
| 204 |
+
|
| 205 |
+
# Compute importance metrics
|
| 206 |
+
importance_abs = np.abs(gradients)
|
| 207 |
+
|
| 208 |
+
# Find most important regions
|
| 209 |
+
top_indices = np.argsort(importance_abs)[-20:] # Top 20 features
|
| 210 |
+
|
| 211 |
+
# Create interpretable output
|
| 212 |
+
feature_importance = {
|
| 213 |
+
'method': 'gradient_saliency',
|
| 214 |
+
'importance_scores': importance_abs.tolist(),
|
| 215 |
+
'top_features': {
|
| 216 |
+
'indices': top_indices.tolist(),
|
| 217 |
+
'values': importance_abs[top_indices].tolist()
|
| 218 |
+
},
|
| 219 |
+
'summary': {
|
| 220 |
+
'max_importance': float(np.max(importance_abs)),
|
| 221 |
+
'mean_importance': float(np.mean(importance_abs)),
|
| 222 |
+
'important_region_start': int(top_indices[0]),
|
| 223 |
+
'important_region_end': int(top_indices[-1])
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return feature_importance
|
| 228 |
+
|
| 229 |
+
except (RuntimeError, ValueError, TypeError) as e:
|
| 230 |
+
print(f"⚠️ Feature importance computation failed: {e}")
|
| 231 |
+
return {
|
| 232 |
+
'method': 'gradient_saliency',
|
| 233 |
+
'error': str(e),
|
| 234 |
+
'importance_scores': [0.0] * len(processed_spectrum)
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
def get_model_info(self) -> List[Dict[str, Any]]:
|
| 238 |
+
"""
|
| 239 |
+
Get information about loaded models.
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
list: List of ModelInfo objects from the centralized manager.
|
| 243 |
+
"""
|
| 244 |
+
return self.model_manager.get_available_models()
|
| 245 |
+
|
| 246 |
+
def batch_predict_with_explanation(
|
| 247 |
+
self,
|
| 248 |
+
spectra: List[SpectrumData],
|
| 249 |
+
model_name: str,
|
| 250 |
+
modality: str, # Add modality for preprocessor
|
| 251 |
+
include_feature_importance: bool = True
|
| 252 |
+
) -> List[Dict[str, Any]]:
|
| 253 |
+
"""
|
| 254 |
+
Batch prediction with explanations.
|
| 255 |
+
|
| 256 |
+
Args:
|
| 257 |
+
spectra (list): List of spectrum data
|
| 258 |
+
model_name (str): Model to use
|
| 259 |
+
modality (str): Spectroscopy modality
|
| 260 |
+
include_feature_importance (bool): Whether to include explanations
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
list: List of prediction results
|
| 264 |
+
"""
|
| 265 |
+
results = []
|
| 266 |
+
|
| 267 |
+
for spectrum in spectra:
|
| 268 |
+
try:
|
| 269 |
+
result = self.predict_with_explanation(
|
| 270 |
+
spectrum,
|
| 271 |
+
model_name,
|
| 272 |
+
modality=modality, # Pass modality down
|
| 273 |
+
include_feature_importance=include_feature_importance
|
| 274 |
+
)
|
| 275 |
+
results.append(result)
|
| 276 |
+
except (HTTPException, ValueError, RuntimeError) as e:
|
| 277 |
+
results.append({
|
| 278 |
+
'error': str(e),
|
| 279 |
+
'spectrum_filename': spectrum.filename
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
return results
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# Global enhanced service instance
|
| 286 |
+
enhanced_ml_service = EnhancedMLService()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def initialize_enhanced_service():
|
| 290 |
+
"""Initialize the enhanced ML service with available models."""
|
| 291 |
+
print("Initializing Enhanced ML Service models...")
|
| 292 |
+
# Iterate through all known models in the registry by calling choices() directly
|
| 293 |
+
for model_name in choices():
|
| 294 |
+
try:
|
| 295 |
+
# Attempt to load each model via the centralized manager
|
| 296 |
+
model_instance, weights_loaded, _ = enhanced_ml_service.model_manager.load_model(
|
| 297 |
+
model_name, TARGET_LEN)
|
| 298 |
+
if model_instance and weights_loaded:
|
| 299 |
+
preprocessor = SpectrumPreprocessor(
|
| 300 |
+
target_len=TARGET_LEN,
|
| 301 |
+
do_baseline=True,
|
| 302 |
+
do_smooth=True,
|
| 303 |
+
do_normalize=True,
|
| 304 |
+
modality="raman"
|
| 305 |
+
)
|
| 306 |
+
enhanced_ml_service.cache_model(model_name, model_instance, preprocessor)
|
| 307 |
+
print(f"✅ Enhanced ML Service: Prepared model '{model_name}' with preprocessor.")
|
| 308 |
+
else:
|
| 309 |
+
print(
|
| 310 |
+
f"⚠️ Enhanced ML Service: Model '{model_name}' not fully loaded or weights missing.")
|
| 311 |
+
except (RuntimeError, ValueError, ImportError) as e:
|
| 312 |
+
print(
|
| 313 |
+
f"❌ Enhanced ML Service: Error initializing model '{model_name}': {e}")
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# Initialize on import
|
| 317 |
+
initialize_enhanced_service()
|
backend/utils/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized error handling utility for the backend API.
|
| 2 |
+
Provides consistent error logging without UI dependencies"""
|
| 3 |
+
|
| 4 |
+
import traceback
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
# Configure logging
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class ErrorHandler:
|
| 12 |
+
"""Centralized error handler for backend operations"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def log_error(error: Exception, context: str = "", include_traceback: bool = False) -> None:
|
| 16 |
+
"""Log error for backend operations"""
|
| 17 |
+
error_msg = f"ERROR {context}: {str(error)}" if context else f"ERROR {str(error)}"
|
| 18 |
+
|
| 19 |
+
if include_traceback:
|
| 20 |
+
error_msg += f"\nTraceback: {traceback.format_exc()}"
|
| 21 |
+
|
| 22 |
+
logger.error(error_msg)
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def log_warning(message: str, context: str = "") -> None:
|
| 26 |
+
"""Log warning for backend operations"""
|
| 27 |
+
warning_msg = f"WARNING {context}: {message}" if context else f"WARNING {message}"
|
| 28 |
+
logger.warning(warning_msg)
|
| 29 |
+
|
| 30 |
+
def safe_execute(func, *args, default_return=None, error_context="", **kwargs):
|
| 31 |
+
"""Safely execute a function and handle errors"""
|
| 32 |
+
try:
|
| 33 |
+
return func(*args, **kwargs)
|
| 34 |
+
except Exception as e:
|
| 35 |
+
ErrorHandler.log_error(e, error_context)
|
| 36 |
+
return default_return
|
backend/utils/model_manager.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from backend.models.registry import (
|
| 6 |
+
build as build_model,
|
| 7 |
+
get_model_info as get_registry_model_info,
|
| 8 |
+
choices,
|
| 9 |
+
)
|
| 10 |
+
from backend.config import TARGET_LEN
|
| 11 |
+
from backend.pydantic_models import ModelInfo
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ModelManager:
|
| 15 |
+
"""
|
| 16 |
+
Centralized manager for discovering, loading, and caching ML models and their weights.
|
| 17 |
+
Ensures consistent model loading logic across different services.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self._model_cache: Dict[str, Dict[str, Any]] = {}
|
| 22 |
+
self._weights_cache: Dict[str, torch.nn.Module] = {}
|
| 23 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 24 |
+
print(f"✅ ModelManager initialized on {self.device}")
|
| 25 |
+
|
| 26 |
+
def _load_state_dict(self, model_path: Path) -> Optional[Dict]:
|
| 27 |
+
"""Production load: Strict security with weights_only enforcement."""
|
| 28 |
+
try:
|
| 29 |
+
if not model_path.exists():
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
mtime = model_path.stat().st_mtime
|
| 33 |
+
cache_key = f"{model_path}:{mtime}"
|
| 34 |
+
|
| 35 |
+
if cache_key not in self._weights_cache:
|
| 36 |
+
# Strictly enforced security load
|
| 37 |
+
self._weights_cache[cache_key] = torch.load(
|
| 38 |
+
model_path, map_location=self.device, weights_only=True
|
| 39 |
+
)
|
| 40 |
+
return self._weights_cache[cache_key]
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"❌ Security/Load Error for {model_path.name}: {e}")
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
def load_model(
|
| 46 |
+
self, model_name: str, target_len: int = TARGET_LEN
|
| 47 |
+
) -> Tuple[torch.nn.Module, bool, Path]:
|
| 48 |
+
"""
|
| 49 |
+
Load a trained model for inference, including its weights.
|
| 50 |
+
Caches the loaded model.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
model_name (str): Name of the model architecture (from registry).
|
| 54 |
+
target_len (int): Expected input length for the model.
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
Tuple[torch.nn.Module, bool, Path]: The loaded model, a boolean indicating
|
| 58 |
+
if weights were successfully loaded, and the path to the loaded weights.
|
| 59 |
+
"""
|
| 60 |
+
# Always use lowercase for filenames
|
| 61 |
+
model_name_lower = model_name.lower()
|
| 62 |
+
# Use absolute path for weights directory
|
| 63 |
+
weights_dir = Path(__file__).parent.parent / "models" / "weights"
|
| 64 |
+
potential_weight_paths = [
|
| 65 |
+
weights_dir / f"{model_name_lower}_model.pth",
|
| 66 |
+
weights_dir / f"{model_name_lower}.pth",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
if model_name_lower in self._model_cache:
|
| 70 |
+
model_entry = self._model_cache[model_name_lower]
|
| 71 |
+
return (
|
| 72 |
+
model_entry["model"],
|
| 73 |
+
model_entry["weights_loaded"],
|
| 74 |
+
model_entry["weights_path"],
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if model_name_lower not in [m.lower() for m in choices()]:
|
| 78 |
+
print(f"⚠️ Model '{model_name_lower}' not found in registry.")
|
| 79 |
+
return None, False, Path("")
|
| 80 |
+
|
| 81 |
+
model = build_model(model_name_lower, target_len)
|
| 82 |
+
weights_loaded = False
|
| 83 |
+
loaded_path = Path("")
|
| 84 |
+
|
| 85 |
+
for weight_path in potential_weight_paths:
|
| 86 |
+
print(f"🔍 Checking for weights at {weight_path}") # Debug log
|
| 87 |
+
if weight_path.exists():
|
| 88 |
+
try:
|
| 89 |
+
state_dict = self._load_state_dict(weight_path)
|
| 90 |
+
if state_dict:
|
| 91 |
+
model.load_state_dict(state_dict, strict=True)
|
| 92 |
+
model.to(self.device)
|
| 93 |
+
model.eval()
|
| 94 |
+
weights_loaded = True
|
| 95 |
+
loaded_path = weight_path
|
| 96 |
+
print(
|
| 97 |
+
f"✅ Loaded weights for {model_name_lower} from {loaded_path}"
|
| 98 |
+
)
|
| 99 |
+
break
|
| 100 |
+
except (OSError, RuntimeError, KeyError) as e:
|
| 101 |
+
print(
|
| 102 |
+
f"❌ Error loading weights for {model_name_lower} from {weight_path}: {e}"
|
| 103 |
+
)
|
| 104 |
+
continue
|
| 105 |
+
else:
|
| 106 |
+
print(f"🔍 Weights not found for {model_name_lower} at {weight_path}")
|
| 107 |
+
|
| 108 |
+
if not weights_loaded:
|
| 109 |
+
print(
|
| 110 |
+
f"⚠️ No weights loaded for model '{model_name_lower}'. Model will use random initialization."
|
| 111 |
+
)
|
| 112 |
+
model.to(self.device)
|
| 113 |
+
model.eval() # Ensure model is in eval mode even if no weights loaded
|
| 114 |
+
|
| 115 |
+
self._model_cache[model_name_lower] = {
|
| 116 |
+
"model": model,
|
| 117 |
+
"weights_loaded": weights_loaded,
|
| 118 |
+
"weights_path": loaded_path,
|
| 119 |
+
"target_len": target_len,
|
| 120 |
+
"device": self.device,
|
| 121 |
+
}
|
| 122 |
+
return model, weights_loaded, loaded_path
|
| 123 |
+
|
| 124 |
+
def get_model_info(self, model_name: str) -> Optional[Dict[str, Any]]:
|
| 125 |
+
"""Get detailed information for a specific model."""
|
| 126 |
+
if model_name not in choices():
|
| 127 |
+
return None
|
| 128 |
+
info = get_registry_model_info(model_name)
|
| 129 |
+
# Add runtime info if model is loaded
|
| 130 |
+
if model_name in self._model_cache:
|
| 131 |
+
cached_info = self._model_cache[model_name]
|
| 132 |
+
info["weights_loaded"] = cached_info["weights_loaded"]
|
| 133 |
+
info["weights_path"] = str(cached_info["weights_path"])
|
| 134 |
+
info["device"] = str(cached_info["device"])
|
| 135 |
+
info["available"] = True
|
| 136 |
+
else:
|
| 137 |
+
# Check if weights exist even if not loaded yet
|
| 138 |
+
model_name = model_name.lower()
|
| 139 |
+
weights_exist = any(
|
| 140 |
+
(Path("backend/models/weights") / f"{model_name}_model.pth").exists()
|
| 141 |
+
or (Path("backend/models/weights") / f"{model_name}.pth").exists()
|
| 142 |
+
for _ in [0]
|
| 143 |
+
) # Dummy loop to check both paths
|
| 144 |
+
info["weights_loaded"] = False
|
| 145 |
+
info["weights_path"] = None
|
| 146 |
+
info["device"] = str(self.device)
|
| 147 |
+
# Mark as available if weights are present
|
| 148 |
+
info["available"] = weights_exist
|
| 149 |
+
|
| 150 |
+
return info
|
| 151 |
+
|
| 152 |
+
def get_available_models(self) -> List[ModelInfo]:
|
| 153 |
+
"""Get a list of all models with their availability status."""
|
| 154 |
+
models_list = []
|
| 155 |
+
for model_name in choices():
|
| 156 |
+
info = self.get_model_info(model_name)
|
| 157 |
+
if info:
|
| 158 |
+
models_list.append(
|
| 159 |
+
ModelInfo(
|
| 160 |
+
name=model_name,
|
| 161 |
+
description=info.get("description", ""),
|
| 162 |
+
input_length=info.get("input_length", TARGET_LEN),
|
| 163 |
+
num_classes=info.get("num_classes", 2),
|
| 164 |
+
supported_modalities=info.get("modalities", ["raman", "ftir"]),
|
| 165 |
+
performance=info.get("performance", {}),
|
| 166 |
+
parameters=info.get("parameters"),
|
| 167 |
+
speed=info.get("speed"),
|
| 168 |
+
citation=info.get("citation"),
|
| 169 |
+
# Use the 'available' status from get_model_info
|
| 170 |
+
available=info.get("available", False),
|
| 171 |
+
)
|
| 172 |
+
)
|
| 173 |
+
return models_list
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Global instance of the ModelManager
|
| 177 |
+
model_manager = ModelManager()
|
backend/utils/multifile.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-file processing utilities for batch inference.
|
| 2 |
+
Handles multiple file uploads and iterative processing.
|
| 3 |
+
Supports TXT, CSV, and JSON file formats with automatic detection."""
|
| 4 |
+
|
| 5 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 6 |
+
import time
|
| 7 |
+
import io
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import numpy as np
|
| 10 |
+
import json
|
| 11 |
+
import csv
|
| 12 |
+
import hashlib
|
| 13 |
+
|
| 14 |
+
from backend.utils.preprocessing import preprocess_spectrum
|
| 15 |
+
from backend.utils.errors import ErrorHandler
|
| 16 |
+
from backend.utils.confidence import calculate_softmax_confidence
|
| 17 |
+
from backend.config import TARGET_LEN
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def detect_file_format(filename: str, content: str) -> str:
|
| 21 |
+
"""Automatically detect file format based on exstention and content
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
filename: Name of the file
|
| 25 |
+
content: Content of the file
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
File format: .'txt', .'csv', .'json'
|
| 29 |
+
"""
|
| 30 |
+
# First try by extension
|
| 31 |
+
suffix = Path(filename).suffix.lower()
|
| 32 |
+
if suffix == ".json":
|
| 33 |
+
try:
|
| 34 |
+
json.loads(content)
|
| 35 |
+
return "json"
|
| 36 |
+
except json.JSONDecodeError:
|
| 37 |
+
pass
|
| 38 |
+
elif suffix == ".csv":
|
| 39 |
+
return "csv"
|
| 40 |
+
elif suffix == ".txt":
|
| 41 |
+
return "txt"
|
| 42 |
+
|
| 43 |
+
# If extension doesn't match or is unclear, try content detection
|
| 44 |
+
content_stripped = content.strip()
|
| 45 |
+
|
| 46 |
+
# Try JSON
|
| 47 |
+
if content_stripped.startswith(("{", "[")):
|
| 48 |
+
try:
|
| 49 |
+
json.loads(content)
|
| 50 |
+
return "json"
|
| 51 |
+
except json.JSONDecodeError:
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
# Try CSV (look for commas in first few lines)
|
| 55 |
+
lines = content_stripped.split("\n")[:5]
|
| 56 |
+
comma_count = sum(line.count(",") for line in lines)
|
| 57 |
+
if comma_count > len(lines): # More commas than lines suggests CSV
|
| 58 |
+
return "csv"
|
| 59 |
+
|
| 60 |
+
# Default to TXT
|
| 61 |
+
return "txt"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def parse_json_spectrum(content: str) -> Tuple[np.ndarray, np.ndarray]:
|
| 65 |
+
"""
|
| 66 |
+
Parse spectrum data from JSON format.
|
| 67 |
+
|
| 68 |
+
Expected formats:
|
| 69 |
+
- {"wavenumbers": [...], "intensities": [...]}
|
| 70 |
+
- {"x": [...], "y": [...]}
|
| 71 |
+
- [{"wavenumber": val, "intensity": val}, ...]
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
data = json.loads(content)
|
| 76 |
+
|
| 77 |
+
# Format 1: Object with arrays
|
| 78 |
+
if isinstance(data, dict):
|
| 79 |
+
x_key = None
|
| 80 |
+
y_key = None
|
| 81 |
+
|
| 82 |
+
# Try common key names for x-axis
|
| 83 |
+
for key in ["wavenumbers", "wavenumber", "x", "freq", "frequency"]:
|
| 84 |
+
if key in data:
|
| 85 |
+
x_key = key
|
| 86 |
+
break
|
| 87 |
+
|
| 88 |
+
# Try common key names for y-axis
|
| 89 |
+
for key in ["intensities", "intensity", "y", "counts", "absorbance"]:
|
| 90 |
+
if key in data:
|
| 91 |
+
y_key = key
|
| 92 |
+
break
|
| 93 |
+
|
| 94 |
+
if x_key and y_key:
|
| 95 |
+
x_vals = np.array(data[x_key], dtype=float)
|
| 96 |
+
y_vals = np.array(data[y_key], dtype=float)
|
| 97 |
+
return x_vals, y_vals
|
| 98 |
+
|
| 99 |
+
# Format 2: Array of objects
|
| 100 |
+
elif isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
|
| 101 |
+
x_vals = []
|
| 102 |
+
y_vals = []
|
| 103 |
+
|
| 104 |
+
for item in data:
|
| 105 |
+
# Try to find x and y values
|
| 106 |
+
x_val = None
|
| 107 |
+
y_val = None
|
| 108 |
+
|
| 109 |
+
for x_key in ["wavenumber", "wavenumbers", "x", "freq"]:
|
| 110 |
+
if x_key in item:
|
| 111 |
+
x_val = float(item[x_key])
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
for y_key in ["intensity", "intensities", "y", "counts"]:
|
| 115 |
+
if y_key in item:
|
| 116 |
+
y_val = float(item[y_key])
|
| 117 |
+
break
|
| 118 |
+
|
| 119 |
+
if x_val is not None and y_val is not None:
|
| 120 |
+
x_vals.append(x_val)
|
| 121 |
+
y_vals.append(y_val)
|
| 122 |
+
|
| 123 |
+
if x_vals and y_vals:
|
| 124 |
+
return np.array(x_vals), np.array(y_vals)
|
| 125 |
+
|
| 126 |
+
raise ValueError(
|
| 127 |
+
"JSON format not recognized. Expected wavenumber/intensity pairs."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
except json.JSONDecodeError as e:
|
| 131 |
+
raise ValueError(f"Invalid JSON format: {str(e)}") from e
|
| 132 |
+
except Exception as e:
|
| 133 |
+
raise ValueError(f"Failed to parse JSON spectrum: {str(e)}") from e
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def parse_csv_spectrum(
|
| 137 |
+
content: str, filename: str = "unknown"
|
| 138 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 139 |
+
"""
|
| 140 |
+
Parse spectrum data from CSV format.
|
| 141 |
+
|
| 142 |
+
Handles various CSV formats with headers or without.
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
# Use StringIO to treat string as file-like object
|
| 146 |
+
csv_file = io.StringIO(content)
|
| 147 |
+
|
| 148 |
+
# Try to detect delimiter
|
| 149 |
+
sample = content[:1024]
|
| 150 |
+
delimiter = ","
|
| 151 |
+
if sample.count(";") > sample.count(","):
|
| 152 |
+
delimiter = ";"
|
| 153 |
+
elif sample.count("\t") > sample.count(","):
|
| 154 |
+
delimiter = "\t"
|
| 155 |
+
|
| 156 |
+
# Read CSV
|
| 157 |
+
csv_reader = csv.reader(csv_file, delimiter=delimiter)
|
| 158 |
+
rows = list(csv_reader)
|
| 159 |
+
|
| 160 |
+
if not rows:
|
| 161 |
+
raise ValueError("Empty CSV file")
|
| 162 |
+
|
| 163 |
+
# Check if first row is header
|
| 164 |
+
has_header = False
|
| 165 |
+
try:
|
| 166 |
+
# If first row contains non-numeric data, it's likely a header
|
| 167 |
+
float(rows[0][0])
|
| 168 |
+
float(rows[0][1])
|
| 169 |
+
except (ValueError, IndexError):
|
| 170 |
+
has_header = True
|
| 171 |
+
|
| 172 |
+
data_rows = rows[1:] if has_header else rows
|
| 173 |
+
|
| 174 |
+
# Extract x and y values
|
| 175 |
+
x_vals = []
|
| 176 |
+
y_vals = []
|
| 177 |
+
|
| 178 |
+
for i, row in enumerate(data_rows):
|
| 179 |
+
if len(row) < 2:
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
x_val = float(row[0])
|
| 184 |
+
y_val = float(row[1])
|
| 185 |
+
x_vals.append(x_val)
|
| 186 |
+
y_vals.append(y_val)
|
| 187 |
+
except ValueError:
|
| 188 |
+
ErrorHandler.log_warning(
|
| 189 |
+
f"Could not parse CSV row {i+1}: {row}", f"Parsing {filename}"
|
| 190 |
+
)
|
| 191 |
+
continue
|
| 192 |
+
|
| 193 |
+
if len(x_vals) < 10:
|
| 194 |
+
raise ValueError(
|
| 195 |
+
f"Insufficient data points ({len(x_vals)}). Need at least 10 points."
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
return np.array(x_vals), np.array(y_vals)
|
| 199 |
+
|
| 200 |
+
except Exception as e:
|
| 201 |
+
raise ValueError(f"Failed to parse CSV spectrum: {str(e)}") from e
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def parse_spectrum_data(
|
| 205 |
+
text_content: str, filename: str = "unknown", file_format: Optional[str] = None
|
| 206 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 207 |
+
"""
|
| 208 |
+
Parse spectrum data from text content with automatic format detection.
|
| 209 |
+
Args:
|
| 210 |
+
text_content: Raw text content of the spectrum file
|
| 211 |
+
filename: Name of the file for error reporting
|
| 212 |
+
file_format: Force specific format ('txt', 'csv', 'json') or None for auto-detection
|
| 213 |
+
Returns:
|
| 214 |
+
Tuple of (x_values, y_values) as numpy arrays
|
| 215 |
+
Raises:
|
| 216 |
+
ValueError: If the data cannot be parsed
|
| 217 |
+
"""
|
| 218 |
+
try:
|
| 219 |
+
# Detect format if not specified
|
| 220 |
+
if file_format is None:
|
| 221 |
+
file_format = detect_file_format(filename, text_content)
|
| 222 |
+
|
| 223 |
+
# Parse based on detected/specified format
|
| 224 |
+
if file_format == "json":
|
| 225 |
+
x, y = parse_json_spectrum(text_content)
|
| 226 |
+
elif file_format == "csv":
|
| 227 |
+
x, y = parse_csv_spectrum(text_content, filename)
|
| 228 |
+
else: # Default to TXT format
|
| 229 |
+
x, y = parse_txt_spectrum(text_content, filename)
|
| 230 |
+
|
| 231 |
+
# Common validation for all formats
|
| 232 |
+
validate_spectrum_data(x, y, filename)
|
| 233 |
+
|
| 234 |
+
return x, y
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
raise ValueError(f"Failed to parse spectrum data: {str(e)}") from e
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def parse_txt_spectrum(
|
| 241 |
+
content: str, filename: str = "unknown"
|
| 242 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 243 |
+
"""Robustly parse spectrum data from TXT format."""
|
| 244 |
+
lines = content.strip().split("\n")
|
| 245 |
+
x_vals, y_vals = [], []
|
| 246 |
+
|
| 247 |
+
for i, line in enumerate(lines):
|
| 248 |
+
line = line.strip()
|
| 249 |
+
if not line or line.startswith(("#", "%")):
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
# Handle different separators
|
| 254 |
+
parts = line.replace(",", " ").replace(";", " ").replace("\t", " ").split()
|
| 255 |
+
|
| 256 |
+
# Find the first two valid numbers in the line
|
| 257 |
+
numbers = []
|
| 258 |
+
for part in parts:
|
| 259 |
+
if part: # Skip empty strings from multiple spaces
|
| 260 |
+
try:
|
| 261 |
+
numbers.append(float(part))
|
| 262 |
+
except ValueError:
|
| 263 |
+
continue # Ignore non-numeric parts
|
| 264 |
+
|
| 265 |
+
if len(numbers) >= 2:
|
| 266 |
+
x_vals.append(numbers[0])
|
| 267 |
+
y_vals.append(numbers[1])
|
| 268 |
+
else:
|
| 269 |
+
ErrorHandler.log_warning(
|
| 270 |
+
f"Could not find two numbers on line {i+1}: '{line}'",
|
| 271 |
+
f"Parsing {filename}",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
except ValueError as e:
|
| 275 |
+
ErrorHandler.log_warning(
|
| 276 |
+
f"Error parsing line {i+1}: '{line}'. Error: {e}",
|
| 277 |
+
f"Parsing {filename}",
|
| 278 |
+
)
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
if len(x_vals) < 10:
|
| 282 |
+
raise ValueError(
|
| 283 |
+
f"Insufficient data points ({len(x_vals)}). Need at least 10 points."
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
return np.array(x_vals), np.array(y_vals)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def validate_spectrum_data(x: np.ndarray, y: np.ndarray, filename: str) -> None:
|
| 290 |
+
"""
|
| 291 |
+
Validate parsed spectrum data for common issues.
|
| 292 |
+
"""
|
| 293 |
+
# Check for NaNs
|
| 294 |
+
if np.any(np.isnan(x)) or np.any(np.isnan(y)):
|
| 295 |
+
raise ValueError("Input data contains NaN values")
|
| 296 |
+
|
| 297 |
+
# Check monotonic increasing x (sort if needed)
|
| 298 |
+
if not np.all(np.diff(x) >= 0):
|
| 299 |
+
# Sort by x values if not monotonic
|
| 300 |
+
sort_idx = np.argsort(x)
|
| 301 |
+
x = x[sort_idx]
|
| 302 |
+
y = y[sort_idx]
|
| 303 |
+
ErrorHandler.log_warning(
|
| 304 |
+
"Wavenumbers were not monotonic - data has been sorted",
|
| 305 |
+
f"Parsing {filename}",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Check reasonable range for spectroscopy
|
| 309 |
+
if min(x) < 0 or max(x) > 10000 or (max(x) - min(x)) < 100:
|
| 310 |
+
ErrorHandler.log_warning(
|
| 311 |
+
f"Unusual wavenumber range: {min(x):.1f} - {max(x):.1f} cm⁻¹",
|
| 312 |
+
f"Parsing {filename}",
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def process_single_file(
|
| 317 |
+
filename: str,
|
| 318 |
+
text_content: str,
|
| 319 |
+
model_choice: str,
|
| 320 |
+
run_inference_func,
|
| 321 |
+
label_file_func,
|
| 322 |
+
modality: str,
|
| 323 |
+
target_len: int,
|
| 324 |
+
) -> Optional[Dict[str, Any]]:
|
| 325 |
+
"""
|
| 326 |
+
Process a single spectrum file
|
| 327 |
+
|
| 328 |
+
Args:
|
| 329 |
+
filename: Name of the file
|
| 330 |
+
text_content: Raw text content
|
| 331 |
+
model_choice: Selected model name
|
| 332 |
+
run_inference_func: Function to run inference
|
| 333 |
+
label_file_func: Function to extract ground truth label
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
Dictionary with processing results or None if failed
|
| 337 |
+
"""
|
| 338 |
+
start_time = time.time()
|
| 339 |
+
|
| 340 |
+
try:
|
| 341 |
+
# 1. Parse spectrum data
|
| 342 |
+
x_raw, y_raw = parse_spectrum_data(text_content, filename)
|
| 343 |
+
|
| 344 |
+
# 2. Preprocess spectrum using the full, modality-aware pipeline
|
| 345 |
+
x_resampled, y_resampled = preprocess_spectrum(
|
| 346 |
+
x_raw, y_raw, modality=modality, target_len=target_len
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# 3. Run inference, passing modality
|
| 350 |
+
cache_key = hashlib.md5(
|
| 351 |
+
f"{y_resampled.tobytes()}{model_choice}".encode()
|
| 352 |
+
).hexdigest()
|
| 353 |
+
prediction, logits_list, probs, logits = run_inference_func(
|
| 354 |
+
y_resampled, model_choice, modality=modality, cache_key=cache_key
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
if prediction is None:
|
| 358 |
+
raise ValueError("Inference returned None. Model may have failed to load.")
|
| 359 |
+
|
| 360 |
+
# ==Calculate confidence==
|
| 361 |
+
if logits is not None:
|
| 362 |
+
probs_np, max_confidence, confidence_level, confidence_emoji = (
|
| 363 |
+
calculate_softmax_confidence(logits)
|
| 364 |
+
)
|
| 365 |
+
else:
|
| 366 |
+
# Fallback for older models or if logits are not returned
|
| 367 |
+
probs_np = np.array(probs) if probs is not None else np.array([])
|
| 368 |
+
max_confidence = float(np.max(probs_np)) if probs_np.size > 0 else 0.0
|
| 369 |
+
confidence_level = "LOW"
|
| 370 |
+
confidence_emoji = "🔴"
|
| 371 |
+
|
| 372 |
+
# ==Get ground truth==
|
| 373 |
+
ground_truth = label_file_func(filename)
|
| 374 |
+
ground_truth = (
|
| 375 |
+
ground_truth if ground_truth is not None and ground_truth >= 0 else None
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# ==Get predicted class==
|
| 379 |
+
label_map = {0: "Stable (Unweathered)", 1: "Weathered (Degraded)"}
|
| 380 |
+
predicted_class = label_map.get(int(prediction), f"Unknown ({prediction})")
|
| 381 |
+
|
| 382 |
+
processing_time = time.time() - start_time
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
"filename": filename,
|
| 386 |
+
"success": True,
|
| 387 |
+
"prediction": int(prediction),
|
| 388 |
+
"predicted_class": predicted_class,
|
| 389 |
+
"confidence": max_confidence,
|
| 390 |
+
"confidence_level": confidence_level,
|
| 391 |
+
"confidence_emoji": confidence_emoji,
|
| 392 |
+
"logits": logits_list if logits_list else [],
|
| 393 |
+
"probabilities": probs_np.tolist() if len(probs_np) > 0 else [],
|
| 394 |
+
"ground_truth": ground_truth,
|
| 395 |
+
"processing_time": processing_time,
|
| 396 |
+
"x_raw": x_raw,
|
| 397 |
+
"y_raw": y_raw,
|
| 398 |
+
"x_resampled": x_resampled,
|
| 399 |
+
"y_resampled": y_resampled,
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
except ValueError as e:
|
| 403 |
+
ErrorHandler.log_error(e, f"processing {filename}")
|
| 404 |
+
return {
|
| 405 |
+
"filename": filename,
|
| 406 |
+
"success": False,
|
| 407 |
+
"error": str(e),
|
| 408 |
+
"processing_time": time.time() - start_time,
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def process_multiple_files(
|
| 413 |
+
uploaded_files: List,
|
| 414 |
+
model_choice: str,
|
| 415 |
+
run_inference_func,
|
| 416 |
+
label_file_func,
|
| 417 |
+
modality: str,
|
| 418 |
+
progress_callback=None,
|
| 419 |
+
) -> List[Dict[str, Any]]:
|
| 420 |
+
"""
|
| 421 |
+
Process multiple uploaded files
|
| 422 |
+
|
| 423 |
+
Args:
|
| 424 |
+
uploaded_files: List of uploaded file objects
|
| 425 |
+
model_choice: Selected model name
|
| 426 |
+
run_inference_func: Function to run inference
|
| 427 |
+
label_file_func: Function to extract ground truth label
|
| 428 |
+
progress_callback: Optional callback to update progress
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
List of processing results
|
| 432 |
+
"""
|
| 433 |
+
results = []
|
| 434 |
+
total_files = len(uploaded_files)
|
| 435 |
+
|
| 436 |
+
ErrorHandler.log_message(
|
| 437 |
+
f"Starting batch processing of {total_files} files with modality '{modality}'"
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
for i, uploaded_file in enumerate(uploaded_files):
|
| 441 |
+
if progress_callback:
|
| 442 |
+
progress_callback(i, total_files, uploaded_file.name)
|
| 443 |
+
|
| 444 |
+
try:
|
| 445 |
+
# ==Read file content==
|
| 446 |
+
raw = uploaded_file.read()
|
| 447 |
+
text_content = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
| 448 |
+
|
| 449 |
+
# ==Process the file==
|
| 450 |
+
result = process_single_file(
|
| 451 |
+
filename=uploaded_file.name,
|
| 452 |
+
text_content=text_content,
|
| 453 |
+
model_choice=model_choice,
|
| 454 |
+
run_inference_func=run_inference_func,
|
| 455 |
+
label_file_func=label_file_func,
|
| 456 |
+
modality=modality,
|
| 457 |
+
target_len=TARGET_LEN,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
if result:
|
| 461 |
+
results.append(result)
|
| 462 |
+
|
| 463 |
+
except ValueError as e:
|
| 464 |
+
ErrorHandler.log_error(e, f"reading file {uploaded_file.name}")
|
| 465 |
+
results.append(
|
| 466 |
+
{
|
| 467 |
+
"filename": uploaded_file.name,
|
| 468 |
+
"success": False,
|
| 469 |
+
"error": f"Failed to read file: {str(e)}",
|
| 470 |
+
}
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
if progress_callback:
|
| 474 |
+
progress_callback(total_files, total_files, "Complete")
|
| 475 |
+
|
| 476 |
+
ErrorHandler.log_message(
|
| 477 |
+
f"Completed batch processing: {sum(1 for r in results if r.get('success', False))}/{total_files} successful"
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
return results
|
backend/utils/performance.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Performance benchmarking and monitoring for the backend API."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import psutil
|
| 5 |
+
import threading
|
| 6 |
+
from typing import Dict, Any, Optional
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import os
|
| 10 |
+
import tempfile
|
| 11 |
+
import sys
|
| 12 |
+
# Configure performance logger
|
| 13 |
+
performance_logger = logging.getLogger('performance')
|
| 14 |
+
performance_logger.setLevel(logging.INFO)
|
| 15 |
+
|
| 16 |
+
# Determine writable log directory (env override), fallback to temp dir
|
| 17 |
+
tmp_base = Path(os.getenv("PERF_LOG_DIR", tempfile.gettempdir()))
|
| 18 |
+
log_dir = tmp_base / "ml_polymer_logs"
|
| 19 |
+
|
| 20 |
+
# Try to create and use a file handler; if that fails, fallback to stdout StreamHandler
|
| 21 |
+
try:
|
| 22 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
file_handler = logging.FileHandler(log_dir / "performance.log", encoding="utf-8")
|
| 24 |
+
file_handler.setLevel(logging.INFO)
|
| 25 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 26 |
+
file_handler.setFormatter(formatter)
|
| 27 |
+
performance_logger.addHandler(file_handler)
|
| 28 |
+
except Exception as e:
|
| 29 |
+
# Fallback to stdout so HF Spaces / container logs capture the output
|
| 30 |
+
stream_handler = logging.StreamHandler(sys.stdout)
|
| 31 |
+
stream_handler.setLevel(logging.INFO)
|
| 32 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 33 |
+
stream_handler.setFormatter(formatter)
|
| 34 |
+
performance_logger.addHandler(stream_handler)
|
| 35 |
+
performance_logger.warning("Could not create file handler for performance logs, using stdout: %s", e)
|
| 36 |
+
|
| 37 |
+
class PerformanceBenchmark:
|
| 38 |
+
"""Context manager for benchmarking operations."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, operation_name: str, metadata: Optional[Dict[str, Any]] = None):
|
| 41 |
+
self.operation_name = operation_name
|
| 42 |
+
self.metadata = metadata or {}
|
| 43 |
+
self.start_time = 0
|
| 44 |
+
self.start_memory = 0
|
| 45 |
+
self.duration = 0
|
| 46 |
+
self.memory_delta = 0
|
| 47 |
+
self.performance_data = {}
|
| 48 |
+
|
| 49 |
+
def __enter__(self):
|
| 50 |
+
self.start_time = time.time()
|
| 51 |
+
self.start_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB
|
| 52 |
+
return self
|
| 53 |
+
|
| 54 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 55 |
+
duration = time.time() - self.start_time
|
| 56 |
+
end_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB
|
| 57 |
+
memory_delta = end_memory - self.start_memory
|
| 58 |
+
|
| 59 |
+
# Log performance data
|
| 60 |
+
perf_data = {
|
| 61 |
+
"operation": self.operation_name,
|
| 62 |
+
"duration_seconds": round(duration, 4),
|
| 63 |
+
"memory_start_mb": round(self.start_memory, 2),
|
| 64 |
+
"memory_end_mb": round(end_memory, 2),
|
| 65 |
+
"memory_delta_mb": round(memory_delta, 2),
|
| 66 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 67 |
+
**self.metadata
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
performance_logger.info(f"BENCHMARK: {perf_data}")
|
| 71 |
+
|
| 72 |
+
# Store in class for retrieval
|
| 73 |
+
self.duration = duration
|
| 74 |
+
self.memory_delta = memory_delta
|
| 75 |
+
self.performance_data = perf_data
|
| 76 |
+
|
| 77 |
+
def log_model_performance(model_name: str, inference_time: float,
|
| 78 |
+
preprocessing_time: float, total_time: float,
|
| 79 |
+
memory_usage: float, spectrum_length: int):
|
| 80 |
+
"""Log model inference performance metrics."""
|
| 81 |
+
perf_data = {
|
| 82 |
+
"operation": "model_inference",
|
| 83 |
+
"model_name": model_name,
|
| 84 |
+
"inference_time": round(inference_time, 4),
|
| 85 |
+
"preprocessing_time": round(preprocessing_time, 4),
|
| 86 |
+
"total_time": round(total_time, 4),
|
| 87 |
+
"memory_usage_mb": round(memory_usage, 2),
|
| 88 |
+
"spectrum_length": spectrum_length,
|
| 89 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
performance_logger.info(f"MODEL_PERF: {perf_data}")
|
| 93 |
+
|
| 94 |
+
def get_system_performance():
|
| 95 |
+
"""Get current system performance metrics."""
|
| 96 |
+
return {
|
| 97 |
+
"cpu_percent": psutil.cpu_percent(interval=1),
|
| 98 |
+
"memory_percent": psutil.virtual_memory().percent,
|
| 99 |
+
"memory_available_mb": psutil.virtual_memory().available / 1024 / 1024,
|
| 100 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 101 |
+
}
|
backend/utils/prepare_data.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Preparation Script
|
| 3 |
+
|
| 4 |
+
This script takes a raw dataset, performs a stratified split into
|
| 5 |
+
training, validation, and test sets, and saves them to a processed
|
| 6 |
+
data directory. This ensures a consistent and reproducible data
|
| 7 |
+
splitting strategy.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/prepare_data.py --data-path /path/to/raw/data.csv --output-path data/processed
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import argparse
|
| 15 |
+
import pandas as pd
|
| 16 |
+
from sklearn.model_selection import train_test_split
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def prepare_data(data_path: Path, output_path: Path, test_size: float = 0.2, val_size: float = 0.15):
|
| 20 |
+
"""
|
| 21 |
+
Loads data, performs stratified train-val-test split, and saves the splits.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
data_path (Path): Path to the raw data file (CSV expected).
|
| 25 |
+
output_path (Path): Directory to save the processed data splits.
|
| 26 |
+
test_size (float): Proportion of the dataset to include in the test split.
|
| 27 |
+
val_size (float): Proportion of the training set to use for validation.
|
| 28 |
+
"""
|
| 29 |
+
if not data_path.exists():
|
| 30 |
+
raise FileNotFoundError(f"Raw data not found at {data_path}")
|
| 31 |
+
|
| 32 |
+
print(f"Loading data from {data_path}...")
|
| 33 |
+
# This assumes a CSV with a 'spectra' column and a 'label' column.
|
| 34 |
+
# You will need to adapt this to your actual raw data format.
|
| 35 |
+
df = pd.read_csv(data_path)
|
| 36 |
+
|
| 37 |
+
# Ensure the 'label' column exists in the dataset
|
| 38 |
+
if 'label' not in df.columns:
|
| 39 |
+
raise ValueError(
|
| 40 |
+
"The input data must contain a 'label' column for stratified splitting.")
|
| 41 |
+
|
| 42 |
+
# Ensure output directory exists
|
| 43 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
print("Performing stratified train-test split...")
|
| 46 |
+
# Split off the test set first
|
| 47 |
+
train_val_df, test_df = train_test_split(
|
| 48 |
+
df, test_size=test_size, stratify=df['label'], random_state=42
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Split the remaining data into training and validation sets
|
| 52 |
+
train_df, val_df = train_test_split(
|
| 53 |
+
train_val_df, test_size=val_size, stratify=train_val_df['label'], random_state=42
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
print(f"Train set size: {len(train_df)}")
|
| 57 |
+
print(f"Validation set size: {len(val_df)}")
|
| 58 |
+
print(f"Test set size: {len(test_df)}")
|
| 59 |
+
|
| 60 |
+
# Save the splits
|
| 61 |
+
train_df.to_csv(output_path / "train.csv", index=False)
|
| 62 |
+
val_df.to_csv(output_path / "validation.csv", index=False)
|
| 63 |
+
test_df.to_csv(output_path / "test.csv", index=False)
|
| 64 |
+
|
| 65 |
+
print(f"✅ Data splits saved to {output_path}")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
parser = argparse.ArgumentParser(
|
| 70 |
+
description="Prepare and split spectral data.")
|
| 71 |
+
parser.add_argument("--data-path", type=Path, required=True,
|
| 72 |
+
help="Path to the raw data CSV file.")
|
| 73 |
+
parser.add_argument("--output-path", type=Path, default=Path(
|
| 74 |
+
"data/processed"), help="Directory to save data splits.")
|
| 75 |
+
args = parser.parse_args()
|
| 76 |
+
prepare_data(args.data_path, args.output_path)
|
backend/utils/preprocessing.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Preprocessing utilities for polymer classification app.
|
| 3 |
+
Adapted from the original scripts/preprocess_dataset.py for Hugging Face Spaces deployment.
|
| 4 |
+
Supports both Raman and FTIR spectroscopy modalities.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import numpy as np
|
| 9 |
+
from numpy.typing import DTypeLike
|
| 10 |
+
from scipy.interpolate import interp1d
|
| 11 |
+
from scipy.signal import savgol_filter
|
| 12 |
+
from typing import Tuple, Literal, Optional
|
| 13 |
+
|
| 14 |
+
TARGET_LENGTH = 500 # Frozen default per PREPROCESSING_BASELINE
|
| 15 |
+
|
| 16 |
+
# Modality-specific validation ranges (cm⁻¹)
|
| 17 |
+
MODALITY_RANGES = {
|
| 18 |
+
"raman": (200, 4000), # Typical Raman range
|
| 19 |
+
"ftir": (400, 4000), # FTIR wavenumber range
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Modality-specific preprocessing parameters
|
| 23 |
+
MODALITY_PARAMS = {
|
| 24 |
+
"raman": {
|
| 25 |
+
"baseline_degree": 2,
|
| 26 |
+
"smooth_window": 11,
|
| 27 |
+
"smooth_polyorder": 2,
|
| 28 |
+
"cosmic_ray_removal": False,
|
| 29 |
+
},
|
| 30 |
+
"ftir": {
|
| 31 |
+
"baseline_degree": 2,
|
| 32 |
+
"smooth_window": 13, # Slightly larger window for FTIR
|
| 33 |
+
"smooth_polyorder": 2,
|
| 34 |
+
"cosmic_ray_removal": False,
|
| 35 |
+
"atmospheric_correction": False, # Placeholder for future implementation
|
| 36 |
+
},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _ensure_1d_equal(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 41 |
+
x = np.asarray(x, dtype=float)
|
| 42 |
+
y = np.asarray(y, dtype=float)
|
| 43 |
+
if x.ndim != 1 or y.ndim != 1 or x.size != y.size or x.size < 2:
|
| 44 |
+
raise ValueError("x and y must be 1D arrays of equal length >= 2")
|
| 45 |
+
return x, y
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def resample_spectrum(
|
| 49 |
+
x: np.ndarray, y: np.ndarray, target_len: int = TARGET_LENGTH
|
| 50 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 51 |
+
"""Linear re-sampling onto a uniform grid of length target_len."""
|
| 52 |
+
x, y = _ensure_1d_equal(x, y)
|
| 53 |
+
order = np.argsort(x)
|
| 54 |
+
x_sorted, y_sorted = x[order], y[order]
|
| 55 |
+
x_new = np.linspace(x_sorted[0], x_sorted[-1], int(target_len))
|
| 56 |
+
f = interp1d(x_sorted, y_sorted, kind="linear", assume_sorted=True)
|
| 57 |
+
y_new = f(x_new)
|
| 58 |
+
return x_new, y_new
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def remove_baseline(y: np.ndarray, degree: int = 2) -> np.ndarray:
|
| 62 |
+
"""Polynomial baseline subtraction (degree=2 default)"""
|
| 63 |
+
y = np.asarray(y, dtype=float)
|
| 64 |
+
x_idx = np.arange(y.size, dtype=float)
|
| 65 |
+
coeffs = np.polyfit(x_idx, y, deg=int(degree))
|
| 66 |
+
baseline = np.polyval(coeffs, x_idx)
|
| 67 |
+
return y - baseline
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def smooth_spectrum(
|
| 71 |
+
y: np.ndarray, window_length: int = 11, polyorder: int = 2
|
| 72 |
+
) -> np.ndarray:
|
| 73 |
+
"""Savitzky-Golay smoothing with safe/odd window enforcement"""
|
| 74 |
+
y = np.asarray(y, dtype=float)
|
| 75 |
+
window_length = int(window_length)
|
| 76 |
+
polyorder = int(polyorder)
|
| 77 |
+
# === window must be odd and >= polyorder+1 ===
|
| 78 |
+
if window_length % 2 == 0:
|
| 79 |
+
window_length += 1
|
| 80 |
+
min_win = polyorder + 1
|
| 81 |
+
if min_win % 2 == 0:
|
| 82 |
+
min_win += 1
|
| 83 |
+
window_length = max(window_length, min_win)
|
| 84 |
+
return savgol_filter(
|
| 85 |
+
y, window_length=window_length, polyorder=polyorder, mode="interp"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def normalize_spectrum(y: np.ndarray) -> np.ndarray:
|
| 90 |
+
"""Min-max normalization to [0, 1] with constant-signal guard."""
|
| 91 |
+
y = np.asarray(y, dtype=float)
|
| 92 |
+
y_min = float(np.min(y))
|
| 93 |
+
y_max = float(np.max(y))
|
| 94 |
+
if np.isclose(y_max - y_min, 0.0):
|
| 95 |
+
return np.zeros_like(y)
|
| 96 |
+
return (y - y_min) / (y_max - y_min)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def validate_spectrum_range(x: np.ndarray, modality: str = "raman") -> bool:
|
| 100 |
+
"""Validate that spectrum wavenumbers are within expected range for modality."""
|
| 101 |
+
if modality not in MODALITY_RANGES:
|
| 102 |
+
raise ValueError(
|
| 103 |
+
f"Unknown modality '{modality}'. Supported: {list(MODALITY_RANGES.keys())}"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
min_range, max_range = MODALITY_RANGES[modality]
|
| 107 |
+
x_min, x_max = np.min(x), np.max(x)
|
| 108 |
+
|
| 109 |
+
# Check if majority of data points are within range
|
| 110 |
+
in_range = np.sum((x >= min_range) & (x <= max_range))
|
| 111 |
+
total_points = len(x)
|
| 112 |
+
|
| 113 |
+
return bool((in_range / total_points) >= 0.7) # At least 70% should be in range
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def validate_spectrum_modality(
|
| 117 |
+
x_data: np.ndarray, y_data: np.ndarray, selected_modality: str
|
| 118 |
+
) -> Tuple[bool, list[str]]:
|
| 119 |
+
"""
|
| 120 |
+
Validate that spectrum characteristics match the selected modality.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
x_data: Wavenumber array (cm⁻¹)
|
| 124 |
+
y_data: Intensity array
|
| 125 |
+
selected_modality: Selected modality ('raman' or 'ftir')
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Tuple of (is_valid, list_of_issues)
|
| 129 |
+
"""
|
| 130 |
+
x_data = np.asarray(x_data)
|
| 131 |
+
y_data = np.asarray(y_data)
|
| 132 |
+
issues = []
|
| 133 |
+
|
| 134 |
+
if selected_modality not in MODALITY_RANGES:
|
| 135 |
+
issues.append(f"Unknown modality: {selected_modality}")
|
| 136 |
+
return False, issues
|
| 137 |
+
|
| 138 |
+
expected_min, expected_max = MODALITY_RANGES[selected_modality]
|
| 139 |
+
actual_min, actual_max = np.min(x_data), np.max(x_data)
|
| 140 |
+
|
| 141 |
+
# Check wavenumber range
|
| 142 |
+
if actual_min < expected_min * 0.8: # Allow 20% tolerance
|
| 143 |
+
issues.append(
|
| 144 |
+
f"Minimum wavenumber ({actual_min:.0f} cm⁻��) is below typical {selected_modality.upper()} range (>{expected_min} cm⁻¹)"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if actual_max > expected_max * 1.2: # Allow 20% tolerance
|
| 148 |
+
issues.append(
|
| 149 |
+
f"Maximum wavenumber ({actual_max:.0f} cm⁻¹) is above typical {selected_modality.upper()} range (<{expected_max} cm⁻¹)"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Check for reasonable data range coverage
|
| 153 |
+
data_range = actual_max - actual_min
|
| 154 |
+
expected_range = expected_max - expected_min
|
| 155 |
+
if data_range < expected_range * 0.3: # Should cover at least 30% of expected range
|
| 156 |
+
issues.append(
|
| 157 |
+
f"Data range ({data_range:.0f} cm⁻¹) seems narrow for {selected_modality.upper()} spectroscopy"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# FTIR-specific checks
|
| 161 |
+
if selected_modality == "ftir":
|
| 162 |
+
# Check for typical FTIR characteristics
|
| 163 |
+
if actual_min > 1000: # FTIR usually includes fingerprint region
|
| 164 |
+
issues.append(
|
| 165 |
+
"FTIR data should typically include fingerprint region (400-1500 cm⁻¹)"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Raman-specific checks
|
| 169 |
+
if selected_modality == "raman":
|
| 170 |
+
# Check for typical Raman characteristics
|
| 171 |
+
if actual_max < 1000: # Raman usually extends to higher wavenumbers
|
| 172 |
+
issues.append(
|
| 173 |
+
"Raman data typically extends to higher wavenumbers (>1000 cm⁻¹)"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return len(issues) == 0, issues
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def preprocess_spectrum(
|
| 180 |
+
x: np.ndarray,
|
| 181 |
+
y: np.ndarray,
|
| 182 |
+
*,
|
| 183 |
+
target_len: int = TARGET_LENGTH,
|
| 184 |
+
modality: str = "raman", # New parameter for modality-specific processing
|
| 185 |
+
do_baseline: bool = True,
|
| 186 |
+
degree: int | None = None, # Will use modality default if None
|
| 187 |
+
do_smooth: bool = True,
|
| 188 |
+
window_length: int | None = None, # Will use modality default if None
|
| 189 |
+
polyorder: int | None = None, # Will use modality default if None
|
| 190 |
+
do_normalize: bool = True,
|
| 191 |
+
out_dtype: DTypeLike = np.float32,
|
| 192 |
+
validate_range: bool = True,
|
| 193 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 194 |
+
"""
|
| 195 |
+
Modality-aware preprocessing: resample -> baseline -> smooth -> normalize
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
x, y: Input spectrum data
|
| 199 |
+
target_len: Target length for resampling
|
| 200 |
+
modality: 'raman' or 'ftir' for modality-specific processing
|
| 201 |
+
do_baseline: Enable baseline correction
|
| 202 |
+
degree: Polynomial degree for baseline (uses modality default if None)
|
| 203 |
+
do_smooth: Enable smoothing
|
| 204 |
+
window_length: Smoothing window length (uses modality default if None)
|
| 205 |
+
polyorder: Polynomial order for smoothing (uses modality default if None)
|
| 206 |
+
do_normalize: Enable normalization
|
| 207 |
+
out_dtype: Output data type
|
| 208 |
+
validate_range: Check if wavenumbers are in expected range for modality
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Tuple of (resampled_x, processed_y)
|
| 212 |
+
"""
|
| 213 |
+
# Validate modality
|
| 214 |
+
if modality not in MODALITY_PARAMS:
|
| 215 |
+
raise ValueError(
|
| 216 |
+
f"Unsupported modality '{modality}'. Supported: {list(MODALITY_PARAMS.keys())}"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# Get modality-specific parameters
|
| 220 |
+
modality_config = MODALITY_PARAMS[modality]
|
| 221 |
+
|
| 222 |
+
# Use modality defaults if parameters not specified
|
| 223 |
+
if degree is None:
|
| 224 |
+
degree = modality_config["baseline_degree"]
|
| 225 |
+
if window_length is None:
|
| 226 |
+
window_length = modality_config["smooth_window"]
|
| 227 |
+
if polyorder is None:
|
| 228 |
+
polyorder = modality_config["smooth_polyorder"]
|
| 229 |
+
|
| 230 |
+
# Validate spectrum range if requested
|
| 231 |
+
if validate_range:
|
| 232 |
+
if not validate_spectrum_range(x, modality):
|
| 233 |
+
print(
|
| 234 |
+
f"Warning: Spectrum wavenumbers may not be optimal for {modality.upper()} analysis"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Standard preprocessing pipeline
|
| 238 |
+
x_rs, y_rs = resample_spectrum(x, y, target_len=target_len)
|
| 239 |
+
|
| 240 |
+
if do_baseline:
|
| 241 |
+
y_rs = remove_baseline(y_rs, degree=degree)
|
| 242 |
+
|
| 243 |
+
if do_smooth:
|
| 244 |
+
y_rs = smooth_spectrum(y_rs, window_length=window_length, polyorder=polyorder)
|
| 245 |
+
|
| 246 |
+
# FTIR-specific processing
|
| 247 |
+
if modality == "ftir":
|
| 248 |
+
if modality_config.get("atmospheric_correction", False):
|
| 249 |
+
y_rs = remove_atmospheric_interference(y_rs)
|
| 250 |
+
if modality_config.get("water_correction", False):
|
| 251 |
+
y_rs = remove_water_vapor_bands(y_rs, x_rs)
|
| 252 |
+
|
| 253 |
+
if do_normalize:
|
| 254 |
+
y_rs = normalize_spectrum(y_rs)
|
| 255 |
+
|
| 256 |
+
# === Coerce to a real dtype to satisfy static checkers & runtime ===
|
| 257 |
+
out_dt = np.dtype(out_dtype)
|
| 258 |
+
return x_rs.astype(out_dt, copy=False), y_rs.astype(out_dt, copy=False)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def remove_atmospheric_interference(y: np.ndarray) -> np.ndarray:
|
| 262 |
+
"""Remove atmospheric CO2 and H2O interference common in FTIR."""
|
| 263 |
+
y = np.asarray(y, dtype=float)
|
| 264 |
+
|
| 265 |
+
# Simple atmospheric correction using median filtering
|
| 266 |
+
# This is a basic implementation - in practice would use reference spectra
|
| 267 |
+
from scipy.signal import medfilt
|
| 268 |
+
|
| 269 |
+
# Apply median filter to reduce sharp atmospheric lines
|
| 270 |
+
corrected = medfilt(y, kernel_size=5)
|
| 271 |
+
|
| 272 |
+
# Blend with original to preserve peak structure
|
| 273 |
+
alpha = 0.7 # Weight for original spectrum
|
| 274 |
+
return alpha * y + (1 - alpha) * corrected
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def remove_water_vapor_bands(y: np.ndarray, x: np.ndarray) -> np.ndarray:
|
| 278 |
+
"""Remove water vapor interference bands in FTIR spectra."""
|
| 279 |
+
y = np.asarray(y, dtype=float)
|
| 280 |
+
x = np.asarray(x, dtype=float)
|
| 281 |
+
|
| 282 |
+
# Common water vapor regions in FTIR (cm⁻¹)
|
| 283 |
+
water_regions = [(3500, 3800), (1300, 1800)]
|
| 284 |
+
|
| 285 |
+
corrected_y = y.copy()
|
| 286 |
+
|
| 287 |
+
for low, high in water_regions:
|
| 288 |
+
# Find indices in water vapor region
|
| 289 |
+
mask = (x >= low) & (x <= high)
|
| 290 |
+
if np.any(mask):
|
| 291 |
+
# Simple linear interpolation across water regions
|
| 292 |
+
indices = np.where(mask)[0]
|
| 293 |
+
if len(indices) > 2:
|
| 294 |
+
start_idx, end_idx = indices[0], indices[-1]
|
| 295 |
+
if start_idx > 0 and end_idx < len(y) - 1:
|
| 296 |
+
# Linear interpolation between boundary points
|
| 297 |
+
start_val = y[start_idx - 1]
|
| 298 |
+
end_val = y[end_idx + 1]
|
| 299 |
+
interp_vals = np.linspace(start_val, end_val, len(indices))
|
| 300 |
+
corrected_y[mask] = interp_vals
|
| 301 |
+
|
| 302 |
+
return corrected_y
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def apply_ftir_specific_processing(
|
| 306 |
+
x: np.ndarray,
|
| 307 |
+
y: np.ndarray,
|
| 308 |
+
atmospheric_correction: bool = False,
|
| 309 |
+
water_correction: bool = False,
|
| 310 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 311 |
+
"""Apply FTIR-specific preprocessing steps."""
|
| 312 |
+
processed_y = y.copy()
|
| 313 |
+
|
| 314 |
+
if atmospheric_correction:
|
| 315 |
+
processed_y = remove_atmospheric_interference(processed_y)
|
| 316 |
+
|
| 317 |
+
if water_correction:
|
| 318 |
+
processed_y = remove_water_vapor_bands(processed_y, x)
|
| 319 |
+
|
| 320 |
+
return x, processed_y
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def get_modality_info(modality: str) -> dict:
|
| 324 |
+
"""Get processing parameters and validation ranges for a modality."""
|
| 325 |
+
if modality not in MODALITY_PARAMS:
|
| 326 |
+
raise ValueError(f"Unknown modality '{modality}'")
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"range": MODALITY_RANGES[modality],
|
| 330 |
+
"params": MODALITY_PARAMS[modality].copy(),
|
| 331 |
+
}
|
backend/utils/preprocessing_fixed.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=missing-function-docstring, missing-class-docstring, missing-module-docstring, redefined-outer-name, unused-argument, unused-import, singleton-comparison, invalid-name, wrong-import-position, too-many-arguments, too-many-locals, too-many-statements, wrong-import-order
|
| 2 |
+
"""
|
| 3 |
+
preprocessing_fixed.py
|
| 4 |
+
Data leakage-free preprocessing pipeline for polymer aging classification.
|
| 5 |
+
This module ensures that preprocessing transformations (normalization, scaling, etc.)
|
| 6 |
+
are fitted only on training data within each cross-validation fold.
|
| 7 |
+
CRITICAL: This fixes the data leakage issue where preprocessing was applied
|
| 8 |
+
to the entire dataset before cross-validation splits.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import numpy as np
|
| 14 |
+
from typing import Tuple, Optional, Dict, Any
|
| 15 |
+
|
| 16 |
+
# Add parent directory to path for imports
|
| 17 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
| 18 |
+
|
| 19 |
+
from .raman_util import list_txt_files, label_file, load_spectrum
|
| 20 |
+
from backend.utils.preprocessing import preprocess_spectrum, TARGET_LENGTH
|
| 21 |
+
|
| 22 |
+
class SpectrumPreprocessor:
|
| 23 |
+
"""
|
| 24 |
+
Data leakage-free preprocessing pipeline for spectral data.
|
| 25 |
+
|
| 26 |
+
This class ensures that normalization and other transformations
|
| 27 |
+
are fitted only on training data within each CV fold.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(
|
| 31 |
+
self,
|
| 32 |
+
target_len: int = TARGET_LENGTH,
|
| 33 |
+
do_baseline: bool = True,
|
| 34 |
+
do_smooth: bool = True,
|
| 35 |
+
do_normalize: bool = True,
|
| 36 |
+
modality: str = "raman"
|
| 37 |
+
):
|
| 38 |
+
"""
|
| 39 |
+
Initialize the preprocessor with configuration.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
target_len (int): Target length for resampling
|
| 43 |
+
do_baseline (bool): Whether to apply baseline correction
|
| 44 |
+
do_smooth (bool): Whether to apply smoothing
|
| 45 |
+
do_normalize (bool): Whether to apply normalization
|
| 46 |
+
modality (str): Spectroscopy modality ('raman' or 'ftir')
|
| 47 |
+
"""
|
| 48 |
+
self.target_len = target_len
|
| 49 |
+
self.do_baseline = do_baseline
|
| 50 |
+
self.do_smooth = do_smooth
|
| 51 |
+
self.do_normalize = do_normalize
|
| 52 |
+
self.modality = modality
|
| 53 |
+
|
| 54 |
+
# Stats fitted on training data only
|
| 55 |
+
self.normalization_stats = None
|
| 56 |
+
self.is_fitted = False
|
| 57 |
+
|
| 58 |
+
def load_raw_data(self, dataset_dir: str) -> Tuple[np.ndarray, np.ndarray, list]:
|
| 59 |
+
"""
|
| 60 |
+
Load raw spectrum data without preprocessing.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
dataset_dir (str): Path to dataset directory
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
tuple: (raw_spectra, labels, file_paths)
|
| 67 |
+
"""
|
| 68 |
+
txt_paths = list_txt_files(dataset_dir)
|
| 69 |
+
raw_spectra = []
|
| 70 |
+
labels = []
|
| 71 |
+
valid_files = []
|
| 72 |
+
|
| 73 |
+
for path in txt_paths:
|
| 74 |
+
label = label_file(path)
|
| 75 |
+
if label is None:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
x_raw, y_raw = load_spectrum(path)
|
| 80 |
+
if len(x_raw) < 10:
|
| 81 |
+
continue # Skip files with too few points
|
| 82 |
+
|
| 83 |
+
raw_spectra.append((x_raw, y_raw))
|
| 84 |
+
labels.append(int(label))
|
| 85 |
+
valid_files.append(path)
|
| 86 |
+
|
| 87 |
+
except (IOError, ValueError) as e:
|
| 88 |
+
print(f"⚠️ Warning: Failed to load {path}: {e}")
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
return np.array(raw_spectra, dtype=object), np.array(labels), valid_files
|
| 92 |
+
|
| 93 |
+
def preprocess_single_spectrum(
|
| 94 |
+
self,
|
| 95 |
+
x_raw: np.ndarray,
|
| 96 |
+
y_raw: np.ndarray,
|
| 97 |
+
use_fitted_stats: bool = False
|
| 98 |
+
) -> np.ndarray:
|
| 99 |
+
"""
|
| 100 |
+
Preprocess a single spectrum.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
x_raw (np.ndarray): Raw wavenumber values
|
| 104 |
+
y_raw (np.ndarray): Raw intensity values
|
| 105 |
+
use_fitted_stats (bool): Whether to use fitted normalization stats
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
np.ndarray: Preprocessed spectrum
|
| 109 |
+
"""
|
| 110 |
+
# Apply resampling, baseline correction, and smoothing
|
| 111 |
+
# These don't cause data leakage as they're applied per-sample
|
| 112 |
+
_, y_processed = preprocess_spectrum(
|
| 113 |
+
np.asarray(x_raw),
|
| 114 |
+
np.asarray(y_raw),
|
| 115 |
+
target_len=self.target_len,
|
| 116 |
+
modality=self.modality,
|
| 117 |
+
do_baseline=self.do_baseline,
|
| 118 |
+
do_smooth=self.do_smooth,
|
| 119 |
+
do_normalize=False, # We handle normalization separately
|
| 120 |
+
out_dtype=np.float32
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Apply normalization using fitted stats if available
|
| 124 |
+
if self.do_normalize and use_fitted_stats and self.is_fitted:
|
| 125 |
+
y_processed = self._apply_fitted_normalization(y_processed)
|
| 126 |
+
elif self.do_normalize and not use_fitted_stats:
|
| 127 |
+
# Apply per-sample normalization (min-max)
|
| 128 |
+
y_min, y_max = y_processed.min(), y_processed.max()
|
| 129 |
+
if y_max > y_min:
|
| 130 |
+
y_processed = (y_processed - y_min) / (y_max - y_min)
|
| 131 |
+
|
| 132 |
+
return y_processed
|
| 133 |
+
|
| 134 |
+
def fit_normalization_stats(self, train_spectra: list) -> None:
|
| 135 |
+
"""
|
| 136 |
+
Fit normalization statistics on training data only.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
train_spectra (list): List of (x_raw, y_raw) tuples for training
|
| 140 |
+
"""
|
| 141 |
+
if not self.do_normalize:
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
# Preprocess training spectra without normalization
|
| 145 |
+
processed_spectra = []
|
| 146 |
+
for x_raw, y_raw in train_spectra:
|
| 147 |
+
y_processed = self.preprocess_single_spectrum(
|
| 148 |
+
x_raw, y_raw, use_fitted_stats=False
|
| 149 |
+
)
|
| 150 |
+
processed_spectra.append(y_processed)
|
| 151 |
+
|
| 152 |
+
# Calculate global statistics from training data
|
| 153 |
+
all_values = np.concatenate(processed_spectra)
|
| 154 |
+
self.normalization_stats = {
|
| 155 |
+
'mean': np.mean(all_values),
|
| 156 |
+
'std': np.std(all_values),
|
| 157 |
+
'min': np.min(all_values),
|
| 158 |
+
'max': np.max(all_values)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
self.is_fitted = True
|
| 162 |
+
print("✅ Fitted normalization statistics on training data")
|
| 163 |
+
|
| 164 |
+
def _apply_fitted_normalization(self, spectrum: np.ndarray) -> np.ndarray:
|
| 165 |
+
"""
|
| 166 |
+
Apply fitted normalization to a spectrum.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
spectrum (np.ndarray): Preprocessed spectrum
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
np.ndarray: Normalized spectrum
|
| 173 |
+
"""
|
| 174 |
+
if not self.is_fitted:
|
| 175 |
+
raise ValueError("Normalization stats not fitted. Call fit_normalization_stats first.")
|
| 176 |
+
|
| 177 |
+
# Use min-max normalization based on training data
|
| 178 |
+
stats = self.normalization_stats
|
| 179 |
+
if stats is not None and stats['max'] > stats['min']:
|
| 180 |
+
spectrum = (spectrum - stats['min']) / (stats['max'] - stats['min'])
|
| 181 |
+
|
| 182 |
+
return spectrum
|
| 183 |
+
|
| 184 |
+
def transform_fold(
|
| 185 |
+
self,
|
| 186 |
+
raw_spectra: np.ndarray,
|
| 187 |
+
train_indices: np.ndarray,
|
| 188 |
+
val_indices: np.ndarray
|
| 189 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 190 |
+
"""
|
| 191 |
+
Transform data for a single CV fold without data leakage.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
raw_spectra (np.ndarray): Array of (x_raw, y_raw) tuples
|
| 195 |
+
train_indices (np.ndarray): Training indices for this fold
|
| 196 |
+
val_indices (np.ndarray): Validation indices for this fold
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
tuple: (X_train, X_val) preprocessed data
|
| 200 |
+
"""
|
| 201 |
+
# Get training and validation raw data
|
| 202 |
+
train_raw = raw_spectra[train_indices]
|
| 203 |
+
val_raw = raw_spectra[val_indices]
|
| 204 |
+
|
| 205 |
+
# Fit normalization stats on training data only
|
| 206 |
+
self.fit_normalization_stats(train_raw.tolist())
|
| 207 |
+
|
| 208 |
+
# Preprocess training data
|
| 209 |
+
X_train = []
|
| 210 |
+
for x_raw, y_raw in train_raw:
|
| 211 |
+
processed = self.preprocess_single_spectrum(
|
| 212 |
+
x_raw, y_raw, use_fitted_stats=True
|
| 213 |
+
)
|
| 214 |
+
X_train.append(processed)
|
| 215 |
+
|
| 216 |
+
# Preprocess validation data using fitted stats
|
| 217 |
+
X_val = []
|
| 218 |
+
for x_raw, y_raw in val_raw:
|
| 219 |
+
processed = self.preprocess_single_spectrum(
|
| 220 |
+
x_raw, y_raw, use_fitted_stats=True
|
| 221 |
+
)
|
| 222 |
+
X_val.append(processed)
|
| 223 |
+
|
| 224 |
+
return np.array(X_train), np.array(X_val)
|
| 225 |
+
|
| 226 |
+
def load_data_for_cv(
|
| 227 |
+
dataset_dir: str,
|
| 228 |
+
preprocessor_config: Optional[Dict[str, Any]] = None
|
| 229 |
+
) -> Tuple[np.ndarray, np.ndarray, SpectrumPreprocessor]:
|
| 230 |
+
"""
|
| 231 |
+
Load raw data for cross-validation without data leakage.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
dataset_dir (str): Path to dataset directory
|
| 235 |
+
preprocessor_config (dict): Configuration for preprocessor
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
tuple: (raw_spectra, labels, preprocessor)
|
| 239 |
+
"""
|
| 240 |
+
config = preprocessor_config or {}
|
| 241 |
+
preprocessor = SpectrumPreprocessor(**config)
|
| 242 |
+
|
| 243 |
+
raw_spectra, labels, _ = preprocessor.load_raw_data(dataset_dir)
|
| 244 |
+
|
| 245 |
+
print(f"✅ Loaded {len(raw_spectra)} raw spectra for CV")
|
| 246 |
+
print(f"Class distribution: {np.bincount(labels)}")
|
| 247 |
+
|
| 248 |
+
return raw_spectra, labels, preprocessor
|
| 249 |
+
|
| 250 |
+
def preprocess_holdout_test_set(
|
| 251 |
+
test_spectra: np.ndarray,
|
| 252 |
+
fitted_preprocessor: SpectrumPreprocessor
|
| 253 |
+
) -> np.ndarray:
|
| 254 |
+
"""
|
| 255 |
+
Preprocess hold-out test set using fitted preprocessor.
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
test_spectra (np.ndarray): Raw test spectra
|
| 259 |
+
fitted_preprocessor (SpectrumPreprocessor): Preprocessor fitted on training data
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
np.ndarray: Preprocessed test data
|
| 263 |
+
"""
|
| 264 |
+
if not fitted_preprocessor.is_fitted:
|
| 265 |
+
raise ValueError("Preprocessor must be fitted on training data first")
|
| 266 |
+
|
| 267 |
+
X_test = []
|
| 268 |
+
for x_raw, y_raw in test_spectra:
|
| 269 |
+
processed = fitted_preprocessor.preprocess_single_spectrum(
|
| 270 |
+
x_raw, y_raw, use_fitted_stats=True
|
| 271 |
+
)
|
| 272 |
+
X_test.append(processed)
|
| 273 |
+
|
| 274 |
+
return np.array(X_test)
|
| 275 |
+
|
| 276 |
+
if __name__ == "__main__":
|
| 277 |
+
# Test the data leakage-free preprocessing pipeline
|
| 278 |
+
print("Testing data leakage-free preprocessing pipeline...")
|
| 279 |
+
|
| 280 |
+
# Test with sample data
|
| 281 |
+
dataset_dir = "sample_data"
|
| 282 |
+
|
| 283 |
+
# Load raw data
|
| 284 |
+
raw_spectra, labels, preprocessor = load_data_for_cv(dataset_dir)
|
| 285 |
+
|
| 286 |
+
# Simulate a single CV fold
|
| 287 |
+
from sklearn.model_selection import StratifiedKFold
|
| 288 |
+
|
| 289 |
+
if len(raw_spectra) >= 2:
|
| 290 |
+
cv = StratifiedKFold(n_splits=2, shuffle=True, random_state=42)
|
| 291 |
+
train_idx, val_idx = next(cv.split(raw_spectra, labels))
|
| 292 |
+
|
| 293 |
+
# Transform without data leakage
|
| 294 |
+
X_train, X_val = preprocessor.transform_fold(raw_spectra, train_idx, val_idx)
|
| 295 |
+
|
| 296 |
+
print("✅ Fold transformation completed")
|
| 297 |
+
print(f" Train: {X_train.shape}")
|
| 298 |
+
print(f" Val: {X_val.shape}")
|
| 299 |
+
print(f" Normalization fitted: {preprocessor.is_fitted}")
|
| 300 |
+
|
| 301 |
+
print("✅ Data leakage-free preprocessing test completed!")
|
backend/utils/raman_util.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
def list_txt_files(root_dir):
|
| 9 |
+
"""Recursively lists all .txt files in a directory."""
|
| 10 |
+
txt_files = []
|
| 11 |
+
for dirpath, _, filenames in os.walk(root_dir):
|
| 12 |
+
for file in filenames:
|
| 13 |
+
if file.endswith(".txt"):
|
| 14 |
+
full_path = os.path.join(dirpath, file)
|
| 15 |
+
txt_files.append(full_path)
|
| 16 |
+
return txt_files
|
| 17 |
+
|
| 18 |
+
def label_file(filepath):
|
| 19 |
+
"""
|
| 20 |
+
Assigns label based on filename prefix:
|
| 21 |
+
- 'sta-' => 0 (pristine)
|
| 22 |
+
- 'wea-' => 1 (weathered)
|
| 23 |
+
Returns None if prefix is unknown.
|
| 24 |
+
"""
|
| 25 |
+
filename = os.path.basename(filepath).lower()
|
| 26 |
+
if filename.startswith("sta-"):
|
| 27 |
+
return 0
|
| 28 |
+
elif filename.startswith("wea-"):
|
| 29 |
+
return 1
|
| 30 |
+
else:
|
| 31 |
+
return None # Unknown or irrelevant
|
| 32 |
+
|
| 33 |
+
def load_spectrum(filepath):
|
| 34 |
+
"""Loads a Raman spectrum from a two-column .txt file."""
|
| 35 |
+
x_vals, y_vals = [], []
|
| 36 |
+
with open(filepath, 'r', encoding='utf-8') as file:
|
| 37 |
+
for line in file:
|
| 38 |
+
parts = line.strip().split()
|
| 39 |
+
if len(parts) == 2:
|
| 40 |
+
try:
|
| 41 |
+
x, y = float(parts[0]), float(parts[1])
|
| 42 |
+
x_vals.append(x)
|
| 43 |
+
y_vals.append(y)
|
| 44 |
+
except ValueError:
|
| 45 |
+
continue # Skip lines that can't be converted
|
| 46 |
+
return x_vals, y_vals
|
backend/utils/seeds.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=missing-function-docstring, missing-class-docstring, missing-module-docstring, redefined-outer-name, unused-argument, unused-import, singleton-comparison, broad-except
|
| 2 |
+
"""
|
| 3 |
+
seeds.py
|
| 4 |
+
|
| 5 |
+
Universal reproducibility controls for the polymer aging ML pipeline.
|
| 6 |
+
Provides centralized seed management to ensure consistent results across
|
| 7 |
+
all random operations in training, validation, and inference.
|
| 8 |
+
|
| 9 |
+
* NOTE: This module should be imported and used at the start of any script
|
| 10 |
+
* involving randomness to guarantee reproducible results.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import random
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def set_global_seeds(seed: int = 42):
|
| 20 |
+
"""
|
| 21 |
+
Set random seeds for all major libraries to ensure reproducibility.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
seed (int): Random seed value to use across all libraries
|
| 25 |
+
|
| 26 |
+
Note:
|
| 27 |
+
This function should be called at the beginning of any script
|
| 28 |
+
that involves random operations (training, data splitting, etc.)
|
| 29 |
+
"""
|
| 30 |
+
# Python built-in random
|
| 31 |
+
random.seed(seed)
|
| 32 |
+
|
| 33 |
+
# NumPy random
|
| 34 |
+
np.random.seed(seed)
|
| 35 |
+
|
| 36 |
+
# PyTorch random
|
| 37 |
+
torch.manual_seed(seed)
|
| 38 |
+
|
| 39 |
+
# PyTorch CUDA random (if available)
|
| 40 |
+
if torch.cuda.is_available():
|
| 41 |
+
torch.cuda.manual_seed(seed)
|
| 42 |
+
torch.cuda.manual_seed_all(seed)
|
| 43 |
+
|
| 44 |
+
# Additional CUDA reproducibility settings
|
| 45 |
+
torch.backends.cudnn.deterministic = True
|
| 46 |
+
torch.backends.cudnn.benchmark = False
|
| 47 |
+
|
| 48 |
+
# Set environment variable for Python hash randomization
|
| 49 |
+
os.environ['PYTHONHASHSEED'] = str(seed)
|
| 50 |
+
|
| 51 |
+
print(f"✅ Global seeds set to {seed} for reproducibility")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_default_seed():
|
| 55 |
+
"""
|
| 56 |
+
Get the default seed value used across the project.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
int: Default seed value (42)
|
| 60 |
+
"""
|
| 61 |
+
return 42
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def create_fold_seeds(base_seed: int = 42, num_folds: int = 10):
|
| 65 |
+
"""
|
| 66 |
+
Create deterministic seeds for cross-validation folds.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
base_seed (int): Base seed for generating fold seeds
|
| 70 |
+
num_folds (int): Number of CV folds
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
list: List of unique seeds for each fold
|
| 74 |
+
"""
|
| 75 |
+
# Use base seed to create deterministic but unique seeds for each fold
|
| 76 |
+
np.random.seed(base_seed)
|
| 77 |
+
fold_seeds = np.random.randint(0, 2**31-1, size=num_folds)
|
| 78 |
+
return fold_seeds.tolist()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def create_augmentation_seed(base_seed: int = 42, fold: int = 0):
|
| 82 |
+
"""
|
| 83 |
+
Create a deterministic seed for data augmentation within a specific fold.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
base_seed (int): Base seed
|
| 87 |
+
fold (int): Current fold number
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
int: Deterministic seed for augmentation in this fold
|
| 91 |
+
"""
|
| 92 |
+
return base_seed + 1000 + fold
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def verify_reproducibility():
|
| 96 |
+
"""
|
| 97 |
+
Verify that random operations are reproducible after setting seeds.
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
bool: True if reproducibility check passes
|
| 101 |
+
"""
|
| 102 |
+
# Test Python random
|
| 103 |
+
set_global_seeds(42)
|
| 104 |
+
python_rand_1 = random.random()
|
| 105 |
+
|
| 106 |
+
set_global_seeds(42)
|
| 107 |
+
python_rand_2 = random.random()
|
| 108 |
+
|
| 109 |
+
# Test NumPy random
|
| 110 |
+
set_global_seeds(42)
|
| 111 |
+
numpy_rand_1 = np.random.random()
|
| 112 |
+
|
| 113 |
+
set_global_seeds(42)
|
| 114 |
+
numpy_rand_2 = np.random.random()
|
| 115 |
+
|
| 116 |
+
# Test PyTorch random
|
| 117 |
+
set_global_seeds(42)
|
| 118 |
+
torch_rand_1 = torch.rand(1).item()
|
| 119 |
+
|
| 120 |
+
set_global_seeds(42)
|
| 121 |
+
torch_rand_2 = torch.rand(1).item()
|
| 122 |
+
|
| 123 |
+
# Check if all are reproducible
|
| 124 |
+
python_reproducible = python_rand_1 == python_rand_2
|
| 125 |
+
numpy_reproducible = numpy_rand_1 == numpy_rand_2
|
| 126 |
+
torch_reproducible = torch_rand_1 == torch_rand_2
|
| 127 |
+
|
| 128 |
+
all_reproducible = python_reproducible and numpy_reproducible and torch_reproducible
|
| 129 |
+
|
| 130 |
+
if all_reproducible:
|
| 131 |
+
print("✅ Reproducibility verification passed")
|
| 132 |
+
else:
|
| 133 |
+
print("❌ Reproducibility verification failed")
|
| 134 |
+
print(f" Python: {python_reproducible}")
|
| 135 |
+
print(f" NumPy: {numpy_reproducible}")
|
| 136 |
+
print(f" PyTorch: {torch_reproducible}")
|
| 137 |
+
|
| 138 |
+
return all_reproducible
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == "__main__":
|
| 142 |
+
print("🧪 Testing reproducibility controls...")
|
| 143 |
+
# Test seed setting
|
| 144 |
+
set_global_seeds(42)
|
| 145 |
+
|
| 146 |
+
# Test fold seed generation
|
| 147 |
+
fold_seeds = create_fold_seeds(42, 10)
|
| 148 |
+
print(f"📊 Generated fold seeds: {fold_seeds}")
|
| 149 |
+
|
| 150 |
+
# Test augmentation seed generation
|
| 151 |
+
aug_seeds = [create_augmentation_seed(42, i) for i in range(5)]
|
| 152 |
+
print(f"📊 Generated augmentation seeds: {aug_seeds}")
|
| 153 |
+
|
| 154 |
+
# Verify reproducibility
|
| 155 |
+
verify_reproducibility()
|
| 156 |
+
|
| 157 |
+
print("✅ Reproducibility controls test completed!")
|
backend/utils/spectrum_preprocessor.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized Spectrum Preprocessing Module
|
| 3 |
+
Single source of truth for all preprocessing operations across training, validation, testing, and live inference.
|
| 4 |
+
Ensures no drift between different processing stages.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import numpy as np
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Dict, Any, Tuple, Optional, List
|
| 12 |
+
from dataclasses import dataclass, asdict
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from backend.utils.preprocessing import (
|
| 16 |
+
preprocess_spectrum,
|
| 17 |
+
validate_spectrum_modality,
|
| 18 |
+
MODALITY_PARAMS,
|
| 19 |
+
MODALITY_RANGES,
|
| 20 |
+
TARGET_LENGTH
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class PreprocessingConfig:
|
| 26 |
+
"""Immutable preprocessing configuration."""
|
| 27 |
+
target_length: int = TARGET_LENGTH
|
| 28 |
+
modality: str = "raman"
|
| 29 |
+
do_baseline: bool = True
|
| 30 |
+
baseline_degree: Optional[int] = None # Uses modality default if None
|
| 31 |
+
do_smooth: bool = True
|
| 32 |
+
smooth_window: Optional[int] = None # Uses modality default if None
|
| 33 |
+
smooth_polyorder: Optional[int] = None # Uses modality default if None
|
| 34 |
+
do_normalize: bool = True
|
| 35 |
+
validate_range: bool = True
|
| 36 |
+
version: str = "1.0.0" # Version for config compatibility
|
| 37 |
+
|
| 38 |
+
def __post_init__(self):
|
| 39 |
+
"""Validate configuration and set modality defaults."""
|
| 40 |
+
if self.modality not in MODALITY_PARAMS:
|
| 41 |
+
raise ValueError(f"Invalid modality: {self.modality}")
|
| 42 |
+
|
| 43 |
+
# Set modality defaults if None
|
| 44 |
+
modality_config = MODALITY_PARAMS[self.modality]
|
| 45 |
+
if self.baseline_degree is None:
|
| 46 |
+
object.__setattr__(self, 'baseline_degree', modality_config['baseline_degree'])
|
| 47 |
+
if self.smooth_window is None:
|
| 48 |
+
object.__setattr__(self, 'smooth_window', modality_config['smooth_window'])
|
| 49 |
+
if self.smooth_polyorder is None:
|
| 50 |
+
object.__setattr__(self, 'smooth_polyorder', modality_config['smooth_polyorder'])
|
| 51 |
+
|
| 52 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 53 |
+
"""Convert to dictionary for serialization."""
|
| 54 |
+
return asdict(self)
|
| 55 |
+
|
| 56 |
+
def get_hash(self) -> str:
|
| 57 |
+
"""Get deterministic hash of configuration."""
|
| 58 |
+
config_str = json.dumps(self.to_dict(), sort_keys=True)
|
| 59 |
+
return hashlib.sha256(config_str.encode()).hexdigest()[:16]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class PreprocessingResult:
|
| 64 |
+
"""Result of preprocessing with full provenance."""
|
| 65 |
+
x_processed: np.ndarray
|
| 66 |
+
y_processed: np.ndarray
|
| 67 |
+
x_original: np.ndarray
|
| 68 |
+
y_original: np.ndarray
|
| 69 |
+
config: PreprocessingConfig
|
| 70 |
+
metadata: Dict[str, Any]
|
| 71 |
+
processing_time: float
|
| 72 |
+
timestamp: str
|
| 73 |
+
|
| 74 |
+
def get_content_hash(self) -> str:
|
| 75 |
+
"""Get hash of processed content for drift detection."""
|
| 76 |
+
# Combine config hash with data hash
|
| 77 |
+
config_hash = self.config.get_hash()
|
| 78 |
+
data_hash = hashlib.sha256(
|
| 79 |
+
np.concatenate([self.x_processed, self.y_processed]).tobytes()
|
| 80 |
+
).hexdigest()[:16]
|
| 81 |
+
return f"{config_hash}_{data_hash}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class SpectrumPreprocessor:
|
| 85 |
+
"""
|
| 86 |
+
Centralized spectrum preprocessor ensuring consistent processing across all stages.
|
| 87 |
+
Single source of truth for preprocessing logic.
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def __init__(self, config: Optional[PreprocessingConfig] = None):
|
| 91 |
+
"""Initialize with preprocessing configuration."""
|
| 92 |
+
self.config = config or PreprocessingConfig()
|
| 93 |
+
self._processing_history: List[Dict[str, Any]] = []
|
| 94 |
+
|
| 95 |
+
def process(
|
| 96 |
+
self,
|
| 97 |
+
x: np.ndarray,
|
| 98 |
+
y: np.ndarray,
|
| 99 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 100 |
+
) -> PreprocessingResult:
|
| 101 |
+
"""
|
| 102 |
+
Process spectrum with full provenance tracking.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
x: Input wavenumber array
|
| 106 |
+
y: Input intensity array
|
| 107 |
+
metadata: Optional metadata to include
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
PreprocessingResult with full provenance
|
| 111 |
+
"""
|
| 112 |
+
import time
|
| 113 |
+
start_time = time.time()
|
| 114 |
+
|
| 115 |
+
# Store original data
|
| 116 |
+
x_original = np.array(x, copy=True)
|
| 117 |
+
y_original = np.array(y, copy=True)
|
| 118 |
+
|
| 119 |
+
# Process using centralized function
|
| 120 |
+
x_processed, y_processed = preprocess_spectrum(
|
| 121 |
+
x, y,
|
| 122 |
+
target_len=self.config.target_length,
|
| 123 |
+
modality=self.config.modality,
|
| 124 |
+
do_baseline=self.config.do_baseline,
|
| 125 |
+
degree=self.config.baseline_degree,
|
| 126 |
+
do_smooth=self.config.do_smooth,
|
| 127 |
+
window_length=self.config.smooth_window,
|
| 128 |
+
polyorder=self.config.smooth_polyorder,
|
| 129 |
+
do_normalize=self.config.do_normalize,
|
| 130 |
+
validate_range=self.config.validate_range
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
processing_time = time.time() - start_time
|
| 134 |
+
|
| 135 |
+
# Create metadata
|
| 136 |
+
result_metadata = {
|
| 137 |
+
"original_length": len(x_original),
|
| 138 |
+
"processed_length": len(x_processed),
|
| 139 |
+
"wavenumber_range": [float(x_processed.min()), float(x_processed.max())],
|
| 140 |
+
"intensity_range": [float(y_processed.min()), float(y_processed.max())],
|
| 141 |
+
"modality_validated": validate_spectrum_modality(x_original, y_original, self.config.modality)[0],
|
| 142 |
+
**(metadata or {})
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
# Create result
|
| 146 |
+
result = PreprocessingResult(
|
| 147 |
+
x_processed=x_processed,
|
| 148 |
+
y_processed=y_processed,
|
| 149 |
+
x_original=x_original,
|
| 150 |
+
y_original=y_original,
|
| 151 |
+
config=self.config,
|
| 152 |
+
metadata=result_metadata,
|
| 153 |
+
processing_time=processing_time,
|
| 154 |
+
timestamp=datetime.now().isoformat()
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Track processing
|
| 158 |
+
self._processing_history.append({
|
| 159 |
+
"timestamp": result.timestamp,
|
| 160 |
+
"config_hash": self.config.get_hash(),
|
| 161 |
+
"content_hash": result.get_content_hash(),
|
| 162 |
+
"processing_time": processing_time
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
return result
|
| 166 |
+
|
| 167 |
+
def process_batch(
|
| 168 |
+
self,
|
| 169 |
+
spectra: List[Tuple[np.ndarray, np.ndarray]],
|
| 170 |
+
metadata_list: Optional[List[Dict[str, Any]]] = None
|
| 171 |
+
) -> List[PreprocessingResult]:
|
| 172 |
+
"""Process multiple spectra with consistent configuration."""
|
| 173 |
+
if metadata_list is None:
|
| 174 |
+
metadata_list = [None] * len(spectra)
|
| 175 |
+
|
| 176 |
+
results = []
|
| 177 |
+
for i, (x, y) in enumerate(spectra):
|
| 178 |
+
metadata = metadata_list[i] if i < len(metadata_list) else None
|
| 179 |
+
result = self.process(x, y, metadata)
|
| 180 |
+
results.append(result)
|
| 181 |
+
|
| 182 |
+
return results
|
| 183 |
+
|
| 184 |
+
def get_processing_summary(self) -> Dict[str, Any]:
|
| 185 |
+
"""Get summary of all processing operations."""
|
| 186 |
+
if not self._processing_history:
|
| 187 |
+
return {"total_processed": 0}
|
| 188 |
+
|
| 189 |
+
return {
|
| 190 |
+
"total_processed": len(self._processing_history),
|
| 191 |
+
"config_hash": self.config.get_hash(),
|
| 192 |
+
"config": self.config.to_dict(),
|
| 193 |
+
"processing_times": {
|
| 194 |
+
"min": min(h["processing_time"] for h in self._processing_history),
|
| 195 |
+
"max": max(h["processing_time"] for h in self._processing_history),
|
| 196 |
+
"mean": np.mean([h["processing_time"] for h in self._processing_history])
|
| 197 |
+
},
|
| 198 |
+
"first_processed": self._processing_history[0]["timestamp"],
|
| 199 |
+
"last_processed": self._processing_history[-1]["timestamp"]
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# Global preprocessor instances for different stages
|
| 204 |
+
TRAINING_PREPROCESSOR = SpectrumPreprocessor(PreprocessingConfig(modality="raman"))
|
| 205 |
+
VALIDATION_PREPROCESSOR = SpectrumPreprocessor(PreprocessingConfig(modality="raman"))
|
| 206 |
+
INFERENCE_PREPROCESSOR = SpectrumPreprocessor(PreprocessingConfig(modality="raman"))
|
| 207 |
+
|
| 208 |
+
# Factory function for creating stage-specific preprocessors
|
| 209 |
+
def create_preprocessor(stage: str, modality: str = "raman") -> SpectrumPreprocessor:
|
| 210 |
+
"""
|
| 211 |
+
Create preprocessor for specific stage with identical configuration.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
stage: 'training', 'validation', 'testing', or 'inference'
|
| 215 |
+
modality: 'raman' or 'ftir'
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
SpectrumPreprocessor configured for the stage
|
| 219 |
+
"""
|
| 220 |
+
config = PreprocessingConfig(modality=modality)
|
| 221 |
+
return SpectrumPreprocessor(config)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# Utility functions for external compatibility
|
| 225 |
+
def preprocess_for_inference(x: np.ndarray, y: np.ndarray, modality: str = "raman") -> Tuple[np.ndarray, np.ndarray]:
|
| 226 |
+
"""
|
| 227 |
+
Process spectrum for inference using centralized preprocessor.
|
| 228 |
+
Maintains compatibility with existing code.
|
| 229 |
+
"""
|
| 230 |
+
preprocessor = create_preprocessor("inference", modality)
|
| 231 |
+
result = preprocessor.process(x, y)
|
| 232 |
+
return result.x_processed, result.y_processed
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def preprocess_for_training(x: np.ndarray, y: np.ndarray, modality: str = "raman") -> Tuple[np.ndarray, np.ndarray]:
|
| 236 |
+
"""
|
| 237 |
+
Process spectrum for training using centralized preprocessor.
|
| 238 |
+
Maintains compatibility with existing code.
|
| 239 |
+
"""
|
| 240 |
+
preprocessor = create_preprocessor("training", modality)
|
| 241 |
+
result = preprocessor.process(x, y)
|
| 242 |
+
return result.x_processed, result.y_processed
|
backend/utils/train.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main Training Script
|
| 3 |
+
|
| 4 |
+
This script orchestrates the model training process. It is configuration-driven
|
| 5 |
+
and uses MLflow for experiment tracking.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/train.py --config-path configs/base_config.yaml
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import sys
|
| 13 |
+
import argparse
|
| 14 |
+
import yaml
|
| 15 |
+
from typing import Dict, Optional, Any
|
| 16 |
+
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import torch
|
| 19 |
+
import mlflow
|
| 20 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 21 |
+
from tqdm import tqdm
|
| 22 |
+
|
| 23 |
+
# Ensure the backend is in the path to import registry and preprocessing
|
| 24 |
+
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
| 25 |
+
|
| 26 |
+
from config import TARGET_LEN
|
| 27 |
+
from backend.utils.preprocessing import preprocess_spectrum
|
| 28 |
+
from models.registry import build
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_data(data_path: Path, target_len: int):
|
| 32 |
+
"""Load and preprocess data from a CSV file."""
|
| 33 |
+
df = pd.read_csv(data_path)
|
| 34 |
+
|
| 35 |
+
# This is a placeholder for your actual data loading.
|
| 36 |
+
# You need to parse your 'spectra' column into x and y values.
|
| 37 |
+
# For this example, we assume 'y_values' are stored as a string of numbers.
|
| 38 |
+
# A more robust solution would use np.load or similar if data is saved in binary format.
|
| 39 |
+
|
| 40 |
+
all_y = []
|
| 41 |
+
# This loop is inefficient and for demonstration only. Vectorize in production.
|
| 42 |
+
for _, row in tqdm(df.iterrows(), total=len(df), desc=f"Processing {data_path.name}"):
|
| 43 |
+
# Dummy x_values, as preprocess_spectrum primarily uses y_values
|
| 44 |
+
x_values = range(len(row['spectrum'].split()))
|
| 45 |
+
y_values = [float(y) for y in row['spectrum'].split()]
|
| 46 |
+
_, y_processed = preprocess_spectrum(
|
| 47 |
+
x_values, y_values, modality='raman')
|
| 48 |
+
all_y.append(y_processed)
|
| 49 |
+
|
| 50 |
+
features = torch.tensor(all_y, dtype=torch.float32).unsqueeze(1)
|
| 51 |
+
labels = torch.tensor(df['label'].values, dtype=torch.long)
|
| 52 |
+
|
| 53 |
+
return TensorDataset(features, labels)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def train(config: dict, jobs_db: Optional[Dict[str, Any]] = None, job_id: Optional[str] = None):
|
| 57 |
+
"""Main training and validation loop."""
|
| 58 |
+
try:
|
| 59 |
+
# --- MLflow Setup ---
|
| 60 |
+
mlflow.set_experiment(config['experiment_name'])
|
| 61 |
+
with mlflow.start_run(run_name=config.get('run_name', 'default_run')) as run:
|
| 62 |
+
mlflow.log_params(config)
|
| 63 |
+
if jobs_db and job_id:
|
| 64 |
+
jobs_db[job_id]['mlflow_run_id'] = run.info.run_id
|
| 65 |
+
jobs_db[job_id]['status'] = 'RUNNING'
|
| 66 |
+
print(f"MLflow Run ID: {run.info.run_id}")
|
| 67 |
+
|
| 68 |
+
# --- Data Loading ---
|
| 69 |
+
data_dir = Path(config['data_dir'])
|
| 70 |
+
train_dataset = load_data(data_dir / config['train_csv'], TARGET_LEN)
|
| 71 |
+
val_dataset = load_data(data_dir / config['val_csv'], TARGET_LEN)
|
| 72 |
+
|
| 73 |
+
train_loader = DataLoader(
|
| 74 |
+
train_dataset, batch_size=config['batch_size'], shuffle=True)
|
| 75 |
+
val_loader = DataLoader(val_dataset, batch_size=config['batch_size'])
|
| 76 |
+
|
| 77 |
+
# --- Model, Optimizer, Loss ---
|
| 78 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 79 |
+
print(f"Using device: {device}")
|
| 80 |
+
|
| 81 |
+
model = build(config['model_name'], TARGET_LEN).to(device)
|
| 82 |
+
optimizer = getattr(torch.optim, config['optimizer'])(
|
| 83 |
+
model.parameters(), lr=config['learning_rate'])
|
| 84 |
+
criterion = getattr(torch.nn, config['loss_function'])()
|
| 85 |
+
|
| 86 |
+
# --- Training Loop ---
|
| 87 |
+
best_val_loss = float('inf')
|
| 88 |
+
for epoch in range(config['epochs']):
|
| 89 |
+
model.train()
|
| 90 |
+
train_loss = 0.0
|
| 91 |
+
for features, labels in tqdm(train_loader, desc=f"Epoch {epoch+1}/{config['epochs']} [Train]"):
|
| 92 |
+
features, labels = features.to(device), labels.to(device)
|
| 93 |
+
|
| 94 |
+
optimizer.zero_grad()
|
| 95 |
+
outputs = model(features)
|
| 96 |
+
loss = criterion(outputs, labels)
|
| 97 |
+
loss.backward()
|
| 98 |
+
optimizer.step()
|
| 99 |
+
train_loss += loss.item()
|
| 100 |
+
|
| 101 |
+
avg_train_loss = train_loss / len(train_loader)
|
| 102 |
+
mlflow.log_metric("train_loss", avg_train_loss, step=epoch)
|
| 103 |
+
|
| 104 |
+
# --- Validation Loop ---
|
| 105 |
+
model.eval()
|
| 106 |
+
val_loss = 0.0
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
for features, labels in tqdm(val_loader, desc=f"Epoch {epoch+1}/{config['epochs']} [Val]"):
|
| 109 |
+
features, labels = features.to(device), labels.to(device)
|
| 110 |
+
outputs = model(features)
|
| 111 |
+
loss = criterion(outputs, labels)
|
| 112 |
+
val_loss += loss.item()
|
| 113 |
+
|
| 114 |
+
avg_val_loss = val_loss / len(val_loader)
|
| 115 |
+
mlflow.log_metric("val_loss", avg_val_loss, step=epoch)
|
| 116 |
+
print(
|
| 117 |
+
f"Epoch {epoch+1}: Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}")
|
| 118 |
+
|
| 119 |
+
# --- Progress Update for Web UI ---
|
| 120 |
+
if jobs_db and job_id:
|
| 121 |
+
progress = (epoch + 1) / config['epochs']
|
| 122 |
+
jobs_db[job_id]['progress'] = progress
|
| 123 |
+
jobs_db[job_id]['metrics']['train_loss'].append(avg_train_loss)
|
| 124 |
+
jobs_db[job_id]['metrics']['val_loss'].append(avg_val_loss)
|
| 125 |
+
jobs_db[job_id]['current_epoch'] = epoch + 1
|
| 126 |
+
|
| 127 |
+
# --- Save Best Model ---
|
| 128 |
+
if avg_val_loss < best_val_loss:
|
| 129 |
+
best_val_loss = avg_val_loss
|
| 130 |
+
mlflow.pytorch.log_model(
|
| 131 |
+
model, "model", registered_model_name=f"{config.get('run_name', 'default_run')}_best")
|
| 132 |
+
print(
|
| 133 |
+
f"New best model saved at epoch {epoch+1} with validation loss: {best_val_loss:.4f}")
|
| 134 |
+
|
| 135 |
+
if jobs_db and job_id:
|
| 136 |
+
jobs_db[job_id]['status'] = 'COMPLETED'
|
| 137 |
+
jobs_db[job_id]['progress'] = 1.0
|
| 138 |
+
print("✅ Training complete.")
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"❌ Training failed: {e}")
|
| 142 |
+
if jobs_db and job_id:
|
| 143 |
+
jobs_db[job_id]['status'] = 'FAILED'
|
| 144 |
+
jobs_db[job_id]['error'] = str(e)
|
| 145 |
+
raise
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
parser = argparse.ArgumentParser(
|
| 150 |
+
description="Train a spectral classification model.")
|
| 151 |
+
parser.add_argument(
|
| 152 |
+
"--config-path",
|
| 153 |
+
type=Path,
|
| 154 |
+
required=True,
|
| 155 |
+
help="Path to the YAML configuration file."
|
| 156 |
+
)
|
| 157 |
+
args = parser.parse_args()
|
| 158 |
+
|
| 159 |
+
with open(args.config_path, 'r', encoding='utf-8') as f:
|
| 160 |
+
config = yaml.safe_load(f)
|
| 161 |
+
|
| 162 |
+
# Run training from CLI without web-specific job tracking
|
| 163 |
+
train(config=config)
|
backend/utils/training_engine.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core Training Engine for the POLYMEROS project.
|
| 3 |
+
|
| 4 |
+
This module contains the primary logic for model training and validation,
|
| 5 |
+
encapsulated in a reusable `TrainingEngine` class. It is designed to be
|
| 6 |
+
called by different interfaces, such as the command-line script
|
| 7 |
+
(train_model.py) and the web UI's TrainingManager.
|
| 8 |
+
|
| 9 |
+
This approach ensures that the core training process is consistent,
|
| 10 |
+
maintainable, and follows the DRY (Don't Repeat Yourself) principle.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import numpy as np
|
| 16 |
+
from torch.utils.data import TensorDataset, DataLoader
|
| 17 |
+
from sklearn.metrics import confusion_matrix, accuracy_score
|
| 18 |
+
|
| 19 |
+
from .training_types import (
|
| 20 |
+
TrainingConfig,
|
| 21 |
+
TrainingProgress,
|
| 22 |
+
get_cv_splitter,
|
| 23 |
+
augment_spectral_data,
|
| 24 |
+
)
|
| 25 |
+
from backend.registry import build as build_model
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TrainingEngine:
|
| 29 |
+
"""Encapsulates the core model training and validation logic."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, config: TrainingConfig):
|
| 32 |
+
"""
|
| 33 |
+
Initializes the TrainingEngine with a given configuration.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
config (TrainingConfig): The configuration object for the training run.
|
| 37 |
+
"""
|
| 38 |
+
self.config = config
|
| 39 |
+
self.device = self._get_device()
|
| 40 |
+
|
| 41 |
+
def _get_device(self) -> torch.device:
|
| 42 |
+
"""Selects the appropriate compute device."""
|
| 43 |
+
if self.config.device == "auto":
|
| 44 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 45 |
+
return torch.device(self.config.device)
|
| 46 |
+
|
| 47 |
+
def run(
|
| 48 |
+
self, X: np.ndarray, y: np.ndarray, progress_callback: callable = None
|
| 49 |
+
) -> dict:
|
| 50 |
+
"""
|
| 51 |
+
Executes the full cross-validation training and evaluation loop.
|
| 52 |
+
Args:
|
| 53 |
+
X (np.ndarray): Feature data.
|
| 54 |
+
y (np.ndarray): Label data.
|
| 55 |
+
progress_callback (callable, optional):
|
| 56 |
+
A function to call with
|
| 57 |
+
progress updates. Defaults to None.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
dict: A dictionary containing the final
|
| 61 |
+
results and metrics.
|
| 62 |
+
"""
|
| 63 |
+
cv_splitter = get_cv_splitter(self.config.cv_strategy, self.config.num_folds)
|
| 64 |
+
|
| 65 |
+
fold_accuracies = []
|
| 66 |
+
all_conf_matrices = []
|
| 67 |
+
final_model_state = None
|
| 68 |
+
|
| 69 |
+
for fold, (train_idx, val_idx) in enumerate(cv_splitter.split(X, y), 1):
|
| 70 |
+
if progress_callback:
|
| 71 |
+
progress_callback(
|
| 72 |
+
{
|
| 73 |
+
"type": "fold_start",
|
| 74 |
+
"fold": fold,
|
| 75 |
+
"total_folds": self.config.num_folds,
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
X_train, X_val = X[train_idx], X[val_idx]
|
| 80 |
+
y_train, y_val = y[train_idx], y[val_idx]
|
| 81 |
+
|
| 82 |
+
# Apply data augmentation if enabled
|
| 83 |
+
if self.config.enable_augmentation:
|
| 84 |
+
X_train, y_train = augment_spectral_data(
|
| 85 |
+
X_train, y_train, noise_level=self.config.noise_level
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
train_loader = DataLoader(
|
| 89 |
+
TensorDataset(
|
| 90 |
+
torch.tensor(X_train, dtype=torch.float32),
|
| 91 |
+
torch.tensor(y_train, dtype=torch.long),
|
| 92 |
+
),
|
| 93 |
+
batch_size=self.config.batch_size,
|
| 94 |
+
shuffle=True,
|
| 95 |
+
)
|
| 96 |
+
val_loader = DataLoader(
|
| 97 |
+
TensorDataset(
|
| 98 |
+
torch.tensor(X_val, dtype=torch.float32),
|
| 99 |
+
torch.tensor(y_val, dtype=torch.long),
|
| 100 |
+
)
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
model = build_model(self.config.model_name, self.config.target_len).to(
|
| 104 |
+
self.device
|
| 105 |
+
)
|
| 106 |
+
optimizer = torch.optim.Adam(
|
| 107 |
+
model.parameters(), lr=self.config.learning_rate
|
| 108 |
+
)
|
| 109 |
+
criterion = nn.CrossEntropyLoss()
|
| 110 |
+
|
| 111 |
+
for epoch in range(self.config.epochs):
|
| 112 |
+
model.train()
|
| 113 |
+
running_loss = 0.0
|
| 114 |
+
for inputs, labels in train_loader:
|
| 115 |
+
inputs = inputs.unsqueeze(1).to(self.device)
|
| 116 |
+
labels = labels.to(self.device)
|
| 117 |
+
|
| 118 |
+
optimizer.zero_grad()
|
| 119 |
+
outputs = model(inputs)
|
| 120 |
+
loss = criterion(outputs, labels)
|
| 121 |
+
loss.backward()
|
| 122 |
+
optimizer.step()
|
| 123 |
+
running_loss += loss.item()
|
| 124 |
+
|
| 125 |
+
if progress_callback:
|
| 126 |
+
progress_callback(
|
| 127 |
+
{
|
| 128 |
+
"type": "epoch_end",
|
| 129 |
+
"fold": fold,
|
| 130 |
+
"epoch": epoch + 1,
|
| 131 |
+
"total_epochs": self.config.epochs,
|
| 132 |
+
"loss": running_loss / len(train_loader),
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Validation
|
| 137 |
+
model.eval()
|
| 138 |
+
all_true, all_pred = [], []
|
| 139 |
+
with torch.no_grad():
|
| 140 |
+
for inputs, labels in val_loader:
|
| 141 |
+
inputs = inputs.unsqueeze(1).to(self.device)
|
| 142 |
+
outputs = model(inputs)
|
| 143 |
+
_, predicted = torch.max(outputs, 1)
|
| 144 |
+
all_true.extend(labels.cpu().numpy())
|
| 145 |
+
all_pred.extend(predicted.cpu().numpy())
|
| 146 |
+
|
| 147 |
+
acc = accuracy_score(all_true, all_pred)
|
| 148 |
+
fold_accuracies.append(acc)
|
| 149 |
+
all_conf_matrices.append(confusion_matrix(all_true, all_pred).tolist())
|
| 150 |
+
final_model_state = model.state_dict()
|
| 151 |
+
|
| 152 |
+
if progress_callback:
|
| 153 |
+
progress_callback({"type": "fold_end", "fold": fold, "accuracy": acc})
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"fold_accuracies": fold_accuracies,
|
| 157 |
+
"confusion_matrices": all_conf_matrices,
|
| 158 |
+
"mean_accuracy": np.mean(fold_accuracies),
|
| 159 |
+
"std_accuracy": np.std(fold_accuracies),
|
| 160 |
+
"model_state_dict": final_model_state,
|
| 161 |
+
}
|
backend/utils/training_engine_enhanced.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pylint: disable=missing-function-docstring, missing-class-docstring, missing-module-docstring, redefined-outer-name, unused-argument, unused-import, singleton-comparison, broad-except, invalid-name
|
| 2 |
+
"""
|
| 3 |
+
training_engine_enhanced.py
|
| 4 |
+
|
| 5 |
+
Enhanced training engine with modern ML practices:
|
| 6 |
+
- L2 Weight Decay (regularization)
|
| 7 |
+
- Early Stopping based on validation loss
|
| 8 |
+
- Learning Rate Scheduling (ReduceLROnPlateau)
|
| 9 |
+
- Data leakage-free preprocessing
|
| 10 |
+
- Comprehensive logging and metrics
|
| 11 |
+
|
| 12 |
+
* NOTE: This replaces the original training engine to incorporate
|
| 13 |
+
* best practices for robust model training.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import numpy as np
|
| 21 |
+
from torch.utils.data import TensorDataset, DataLoader
|
| 22 |
+
from torch.optim.lr_scheduler import ReduceLROnPlateau
|
| 23 |
+
from sklearn.metrics import confusion_matrix, accuracy_score, classification_report
|
| 24 |
+
from typing import Dict, Any, Optional, Callable
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
from .utils.preprocessing_fixed import SpectrumPreprocessor, load_data_for_cv
|
| 28 |
+
from .utils.seeds import set_global_seeds, create_fold_seeds
|
| 29 |
+
from .training_types import TrainingConfig, get_cv_splitter
|
| 30 |
+
from backend.registry import build as build_model
|
| 31 |
+
|
| 32 |
+
class EarlyStoppingCallback:
|
| 33 |
+
"""Early stopping callback to prevent overfitting."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, patience: int = 7, min_delta: float = 1e-6):
|
| 36 |
+
self.patience = patience
|
| 37 |
+
self.min_delta = min_delta
|
| 38 |
+
self.best_loss = float('inf')
|
| 39 |
+
self.counter = 0
|
| 40 |
+
self.early_stop = False
|
| 41 |
+
|
| 42 |
+
def __call__(self, val_loss: float) -> bool:
|
| 43 |
+
"""
|
| 44 |
+
Check if training should stop early.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
val_loss (float): Current validation loss
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
bool: True if training should stop
|
| 51 |
+
"""
|
| 52 |
+
if val_loss < self.best_loss - self.min_delta:
|
| 53 |
+
self.best_loss = val_loss
|
| 54 |
+
self.counter = 0
|
| 55 |
+
else:
|
| 56 |
+
self.counter += 1
|
| 57 |
+
if self.counter >= self.patience:
|
| 58 |
+
self.early_stop = True
|
| 59 |
+
|
| 60 |
+
return self.early_stop
|
| 61 |
+
|
| 62 |
+
class EnhancedTrainingEngine:
|
| 63 |
+
"""
|
| 64 |
+
Enhanced training engine with modern ML practices and data leakage prevention.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(self, config: TrainingConfig):
|
| 68 |
+
"""
|
| 69 |
+
Initialize the enhanced training engine.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
config (TrainingConfig): Training configuration
|
| 73 |
+
"""
|
| 74 |
+
self.config = config
|
| 75 |
+
self.device = self._get_device()
|
| 76 |
+
|
| 77 |
+
# Enhanced training parameters
|
| 78 |
+
self.weight_decay = getattr(config, 'weight_decay', 1e-4)
|
| 79 |
+
self.early_stopping_patience = getattr(config, 'early_stopping_patience', 10)
|
| 80 |
+
self.lr_scheduler_patience = getattr(config, 'lr_scheduler_patience', 5)
|
| 81 |
+
self.lr_scheduler_factor = getattr(config, 'lr_scheduler_factor', 0.5)
|
| 82 |
+
self.min_lr = getattr(config, 'min_lr', 1e-6)
|
| 83 |
+
|
| 84 |
+
print("Enhanced Training Engine initialized")
|
| 85 |
+
print(f" Device: {self.device}")
|
| 86 |
+
print(f" Weight Decay: {self.weight_decay}")
|
| 87 |
+
print(f" Early Stopping Patience: {self.early_stopping_patience}")
|
| 88 |
+
print(f" LR Scheduler Patience: {self.lr_scheduler_patience}")
|
| 89 |
+
|
| 90 |
+
def _get_device(self) -> torch.device:
|
| 91 |
+
"""Select the appropriate compute device."""
|
| 92 |
+
if self.config.device == "auto":
|
| 93 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 94 |
+
return torch.device(self.config.device)
|
| 95 |
+
|
| 96 |
+
def run(
|
| 97 |
+
self,
|
| 98 |
+
dataset_dir: str,
|
| 99 |
+
progress_callback: Optional[Callable] = None
|
| 100 |
+
) -> Dict[str, Any]:
|
| 101 |
+
"""
|
| 102 |
+
Run the complete training pipeline with data leakage prevention.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
dataset_dir (str): Path to dataset directory
|
| 106 |
+
progress_callback (callable): Optional progress callback
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
dict: Complete training results and metrics
|
| 110 |
+
"""
|
| 111 |
+
print("Starting enhanced training pipeline...")
|
| 112 |
+
|
| 113 |
+
# Set global seeds for reproducibility
|
| 114 |
+
set_global_seeds(getattr(self.config, 'random_state', 42))
|
| 115 |
+
|
| 116 |
+
# Load raw data without preprocessing
|
| 117 |
+
preprocessor_config = {
|
| 118 |
+
'target_len': self.config.target_len,
|
| 119 |
+
'do_baseline': getattr(self.config, 'baseline_correction', True),
|
| 120 |
+
'do_smooth': getattr(self.config, 'smoothing', True),
|
| 121 |
+
'do_normalize': getattr(self.config, 'normalization', True),
|
| 122 |
+
'modality': getattr(self.config, 'modality', 'raman')
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
raw_spectra, labels, preprocessor = load_data_for_cv(
|
| 126 |
+
dataset_dir, preprocessor_config
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Initialize cross-validation
|
| 130 |
+
cv_splitter = get_cv_splitter(
|
| 131 |
+
getattr(self.config, 'cv_strategy', 'stratified_kfold'),
|
| 132 |
+
self.config.num_folds
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Generate fold-specific seeds
|
| 136 |
+
fold_seeds = create_fold_seeds(
|
| 137 |
+
getattr(self.config, 'random_state', 42),
|
| 138 |
+
self.config.num_folds
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Results storage
|
| 142 |
+
fold_results = []
|
| 143 |
+
all_conf_matrices = []
|
| 144 |
+
|
| 145 |
+
for fold, (train_idx, val_idx) in enumerate(cv_splitter.split(raw_spectra, labels), 1):
|
| 146 |
+
print(f"\nTraining Fold {fold}/{self.config.num_folds}")
|
| 147 |
+
|
| 148 |
+
# Set fold-specific seed
|
| 149 |
+
set_global_seeds(fold_seeds[fold - 1])
|
| 150 |
+
|
| 151 |
+
if progress_callback:
|
| 152 |
+
progress_callback({
|
| 153 |
+
"type": "fold_start",
|
| 154 |
+
"fold": fold,
|
| 155 |
+
"total_folds": self.config.num_folds
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
# Preprocess data for this fold (no data leakage)
|
| 159 |
+
X_train, X_val = preprocessor.transform_fold(raw_spectra, train_idx, val_idx)
|
| 160 |
+
y_train, y_val = labels[train_idx], labels[val_idx]
|
| 161 |
+
|
| 162 |
+
print(f" Train: {X_train.shape}, Val: {X_val.shape}")
|
| 163 |
+
|
| 164 |
+
# Train model for this fold
|
| 165 |
+
fold_result = self._train_single_fold(
|
| 166 |
+
X_train, X_val, y_train, y_val,
|
| 167 |
+
fold, progress_callback
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
fold_results.append(fold_result)
|
| 171 |
+
all_conf_matrices.append(fold_result['confusion_matrix'])
|
| 172 |
+
|
| 173 |
+
print(f"Fold {fold} completed - Accuracy: {fold_result['accuracy']:.4f}")
|
| 174 |
+
|
| 175 |
+
# Aggregate results
|
| 176 |
+
final_results = self._aggregate_results(fold_results, all_conf_matrices)
|
| 177 |
+
|
| 178 |
+
print("\nTraining completed!")
|
| 179 |
+
print(f" Mean Accuracy: {final_results['mean_accuracy']:.4f} ± {final_results['std_accuracy']:.4f}")
|
| 180 |
+
print(f" Best Fold: {final_results['best_fold']} ({final_results['best_accuracy']:.4f})")
|
| 181 |
+
|
| 182 |
+
return final_results
|
| 183 |
+
|
| 184 |
+
def _train_single_fold(
|
| 185 |
+
self,
|
| 186 |
+
X_train: np.ndarray,
|
| 187 |
+
X_val: np.ndarray,
|
| 188 |
+
y_train: np.ndarray,
|
| 189 |
+
y_val: np.ndarray,
|
| 190 |
+
fold: int,
|
| 191 |
+
progress_callback: Optional[Callable] = None
|
| 192 |
+
) -> Dict[str, Any]:
|
| 193 |
+
"""
|
| 194 |
+
Train a model for a single fold with enhanced techniques.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
X_train, X_val, y_train, y_val: Training and validation data
|
| 198 |
+
fold (int): Current fold number
|
| 199 |
+
progress_callback (callable): Optional progress callback
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
dict: Results for this fold
|
| 203 |
+
"""
|
| 204 |
+
# Create data loaders
|
| 205 |
+
train_loader = DataLoader(
|
| 206 |
+
TensorDataset(
|
| 207 |
+
torch.tensor(X_train, dtype=torch.float32),
|
| 208 |
+
torch.tensor(y_train, dtype=torch.long)
|
| 209 |
+
),
|
| 210 |
+
batch_size=self.config.batch_size,
|
| 211 |
+
shuffle=True
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
val_loader = DataLoader(
|
| 215 |
+
TensorDataset(
|
| 216 |
+
torch.tensor(X_val, dtype=torch.float32),
|
| 217 |
+
torch.tensor(y_val, dtype=torch.long)
|
| 218 |
+
),
|
| 219 |
+
batch_size=self.config.batch_size,
|
| 220 |
+
shuffle=False
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Initialize model
|
| 224 |
+
model = build_model(self.config.model_name, self.config.target_len)
|
| 225 |
+
if not isinstance(model, torch.nn.Module):
|
| 226 |
+
raise TypeError(f"Expected a PyTorch model, but got {type(model)}")
|
| 227 |
+
model = model.to(self.device)
|
| 228 |
+
|
| 229 |
+
# Enhanced optimizer with weight decay (L2 regularization)
|
| 230 |
+
optimizer = torch.optim.Adam(
|
| 231 |
+
model.parameters(),
|
| 232 |
+
lr=self.config.learning_rate,
|
| 233 |
+
weight_decay=self.weight_decay
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Learning rate scheduler
|
| 237 |
+
scheduler = ReduceLROnPlateau(
|
| 238 |
+
optimizer,
|
| 239 |
+
mode='min',
|
| 240 |
+
factor=self.lr_scheduler_factor,
|
| 241 |
+
patience=self.lr_scheduler_patience,
|
| 242 |
+
min_lr=self.min_lr,
|
| 243 |
+
verbose='True'
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Early stopping
|
| 247 |
+
early_stopping = EarlyStoppingCallback(patience=self.early_stopping_patience)
|
| 248 |
+
|
| 249 |
+
# Loss function
|
| 250 |
+
criterion = nn.CrossEntropyLoss()
|
| 251 |
+
|
| 252 |
+
# Training loop
|
| 253 |
+
train_losses = []
|
| 254 |
+
val_losses = []
|
| 255 |
+
val_accuracies = []
|
| 256 |
+
|
| 257 |
+
best_val_loss = float('inf')
|
| 258 |
+
best_model_state = None
|
| 259 |
+
epochs_trained = 0
|
| 260 |
+
|
| 261 |
+
for epoch in range(self.config.epochs):
|
| 262 |
+
# Training phase
|
| 263 |
+
model.train()
|
| 264 |
+
train_loss = 0.0
|
| 265 |
+
|
| 266 |
+
for inputs, labels_batch in train_loader:
|
| 267 |
+
inputs = inputs.unsqueeze(1).to(self.device)
|
| 268 |
+
labels_batch = labels_batch.to(self.device)
|
| 269 |
+
|
| 270 |
+
optimizer.zero_grad()
|
| 271 |
+
outputs = model(inputs)
|
| 272 |
+
loss = criterion(outputs, labels_batch)
|
| 273 |
+
loss.backward()
|
| 274 |
+
optimizer.step()
|
| 275 |
+
|
| 276 |
+
train_loss += loss.item()
|
| 277 |
+
|
| 278 |
+
avg_train_loss = train_loss / len(train_loader)
|
| 279 |
+
train_losses.append(avg_train_loss)
|
| 280 |
+
|
| 281 |
+
# Validation phase
|
| 282 |
+
model.eval()
|
| 283 |
+
val_loss = 0.0
|
| 284 |
+
val_correct = 0
|
| 285 |
+
val_total = 0
|
| 286 |
+
|
| 287 |
+
with torch.no_grad():
|
| 288 |
+
for inputs, labels_batch in val_loader:
|
| 289 |
+
inputs = inputs.unsqueeze(1).to(self.device)
|
| 290 |
+
labels_batch = labels_batch.to(self.device)
|
| 291 |
+
|
| 292 |
+
outputs = model(inputs)
|
| 293 |
+
loss = criterion(outputs, labels_batch)
|
| 294 |
+
val_loss += loss.item()
|
| 295 |
+
|
| 296 |
+
_, predicted = torch.max(outputs, 1)
|
| 297 |
+
val_total += labels_batch.size(0)
|
| 298 |
+
val_correct += (predicted == labels_batch).sum().item()
|
| 299 |
+
|
| 300 |
+
avg_val_loss = val_loss / len(val_loader)
|
| 301 |
+
val_accuracy = val_correct / val_total
|
| 302 |
+
|
| 303 |
+
val_losses.append(avg_val_loss)
|
| 304 |
+
val_accuracies.append(val_accuracy)
|
| 305 |
+
|
| 306 |
+
# Learning rate scheduling
|
| 307 |
+
scheduler.step(avg_val_loss)
|
| 308 |
+
|
| 309 |
+
# Save best model
|
| 310 |
+
if avg_val_loss < best_val_loss:
|
| 311 |
+
best_val_loss = avg_val_loss
|
| 312 |
+
best_model_state = model.state_dict().copy()
|
| 313 |
+
|
| 314 |
+
# Progress callback
|
| 315 |
+
if progress_callback:
|
| 316 |
+
progress_callback({
|
| 317 |
+
"type": "epoch_end",
|
| 318 |
+
"fold": fold,
|
| 319 |
+
"epoch": epoch + 1,
|
| 320 |
+
"total_epochs": self.config.epochs,
|
| 321 |
+
"train_loss": avg_train_loss,
|
| 322 |
+
"val_loss": avg_val_loss,
|
| 323 |
+
"val_accuracy": val_accuracy
|
| 324 |
+
})
|
| 325 |
+
|
| 326 |
+
# Early stopping check
|
| 327 |
+
if early_stopping(avg_val_loss):
|
| 328 |
+
print(f" Early stopping at epoch {epoch + 1}")
|
| 329 |
+
epochs_trained = epoch + 1
|
| 330 |
+
break
|
| 331 |
+
|
| 332 |
+
epochs_trained = epoch + 1
|
| 333 |
+
|
| 334 |
+
# Load best model and evaluate
|
| 335 |
+
if best_model_state is not None:
|
| 336 |
+
model.load_state_dict(best_model_state)
|
| 337 |
+
|
| 338 |
+
# Final evaluation
|
| 339 |
+
model.eval()
|
| 340 |
+
all_true = []
|
| 341 |
+
all_pred = []
|
| 342 |
+
|
| 343 |
+
with torch.no_grad():
|
| 344 |
+
for inputs, labels_batch in val_loader:
|
| 345 |
+
inputs = inputs.unsqueeze(1).to(self.device)
|
| 346 |
+
outputs = model(inputs)
|
| 347 |
+
_, predicted = torch.max(outputs, 1)
|
| 348 |
+
|
| 349 |
+
all_true.extend(labels_batch.cpu().numpy())
|
| 350 |
+
all_pred.extend(predicted.cpu().numpy())
|
| 351 |
+
|
| 352 |
+
# Calculate metrics
|
| 353 |
+
accuracy = accuracy_score(all_true, all_pred)
|
| 354 |
+
conf_matrix = confusion_matrix(all_true, all_pred)
|
| 355 |
+
|
| 356 |
+
return {
|
| 357 |
+
'fold': fold,
|
| 358 |
+
'accuracy': accuracy,
|
| 359 |
+
'confusion_matrix': conf_matrix.tolist(),
|
| 360 |
+
'train_losses': train_losses,
|
| 361 |
+
'val_losses': val_losses,
|
| 362 |
+
'val_accuracies': val_accuracies,
|
| 363 |
+
'epochs_trained': epochs_trained,
|
| 364 |
+
'best_val_loss': best_val_loss,
|
| 365 |
+
'model_state': best_model_state
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
def _aggregate_results(
|
| 369 |
+
self,
|
| 370 |
+
fold_results: list,
|
| 371 |
+
all_conf_matrices: list
|
| 372 |
+
) -> Dict[str, Any]:
|
| 373 |
+
"""
|
| 374 |
+
Aggregate results across all folds.
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
fold_results (list): Results from each fold
|
| 378 |
+
all_conf_matrices (list): Confusion matrices from each fold
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
dict: Aggregated results
|
| 382 |
+
"""
|
| 383 |
+
accuracies = [result['accuracy'] for result in fold_results]
|
| 384 |
+
|
| 385 |
+
# Find best fold
|
| 386 |
+
best_fold_idx = np.argmax(accuracies)
|
| 387 |
+
best_fold = fold_results[best_fold_idx]
|
| 388 |
+
|
| 389 |
+
return {
|
| 390 |
+
'fold_results': fold_results,
|
| 391 |
+
'accuracies': accuracies,
|
| 392 |
+
'mean_accuracy': float(np.mean(accuracies)),
|
| 393 |
+
'std_accuracy': float(np.std(accuracies)),
|
| 394 |
+
'best_fold': best_fold['fold'],
|
| 395 |
+
'best_accuracy': float(best_fold['accuracy']),
|
| 396 |
+
'best_model_state': best_fold['model_state'],
|
| 397 |
+
'confusion_matrices': all_conf_matrices,
|
| 398 |
+
'config': self.config.__dict__ if hasattr(self.config, '__dict__') else str(self.config)
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
# Test the enhanced training engine
|
| 404 |
+
print("Testing Enhanced Training Engine...")
|
| 405 |
+
|
| 406 |
+
# Create a minimal config for testing
|
| 407 |
+
class TestConfig(TrainingConfig):
|
| 408 |
+
model_name = "figure2"
|
| 409 |
+
target_len = 500
|
| 410 |
+
batch_size = 16
|
| 411 |
+
epochs = 2 # Short for testing
|
| 412 |
+
learning_rate = 1e-3
|
| 413 |
+
num_folds = 2 # Small for testing
|
| 414 |
+
device = "cpu"
|
| 415 |
+
weight_decay = 1e-4
|
| 416 |
+
early_stopping_patience = 5
|
| 417 |
+
|
| 418 |
+
config = TestConfig(
|
| 419 |
+
model_name="figure2",
|
| 420 |
+
dataset_path="sample_data"
|
| 421 |
+
)
|
| 422 |
+
engine = EnhancedTrainingEngine(config)
|
| 423 |
+
|
| 424 |
+
# Test with sample data (will work even with small dataset)
|
| 425 |
+
try:
|
| 426 |
+
results = engine.run("sample_data")
|
| 427 |
+
print("✅ Enhanced training engine test completed!")
|
| 428 |
+
print(f" Results keys: {list(results.keys())}")
|
| 429 |
+
except Exception as e:
|
| 430 |
+
print(f"⚠️ Test failed (expected with minimal data): {e}")
|
| 431 |
+
print("✅ Enhanced training engine structure validated")
|
backend/utils/training_manager.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training job management system for ML Hub functionality.
|
| 3 |
+
Handles asynchronous training jobs, progress tracking, and result management.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
import threading
|
| 12 |
+
import concurrent.futures
|
| 13 |
+
import multiprocessing
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
from typing import Dict, List, Optional, Callable, Any, Tuple
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import numpy as np
|
| 22 |
+
from torch.utils.data import TensorDataset, DataLoader
|
| 23 |
+
from sklearn.metrics import confusion_matrix, accuracy_score, f1_score
|
| 24 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 25 |
+
from scipy.signal import find_peaks
|
| 26 |
+
from scipy.spatial.distance import euclidean
|
| 27 |
+
|
| 28 |
+
# Add project-specific imports
|
| 29 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 30 |
+
from backend.registry import choices as model_choices, build as build_model
|
| 31 |
+
from utils.training_engine import TrainingEngine
|
| 32 |
+
from utils.training_types import (
|
| 33 |
+
TrainingConfig,
|
| 34 |
+
TrainingProgress,
|
| 35 |
+
TrainingStatus,
|
| 36 |
+
CVStrategy,
|
| 37 |
+
get_cv_splitter,
|
| 38 |
+
)
|
| 39 |
+
from backend.utils.preprocessing import preprocess_spectrum
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def spectral_cosine_similarity(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
| 43 |
+
"""Calculate cosine similarity between spectral predictions and true values"""
|
| 44 |
+
# Reshape if needed for cosine similarity calculation
|
| 45 |
+
if y_true.ndim == 1:
|
| 46 |
+
y_true = y_true.reshape(1, -1)
|
| 47 |
+
if y_pred.ndim == 1:
|
| 48 |
+
y_pred = y_pred.reshape(1, -1)
|
| 49 |
+
|
| 50 |
+
return float(cosine_similarity(y_true, y_pred)[0, 0])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def peak_matching_score(
|
| 54 |
+
spectrum1: np.ndarray,
|
| 55 |
+
spectrum2: np.ndarray,
|
| 56 |
+
height_threshold: float = 0.1,
|
| 57 |
+
distance: int = 5,
|
| 58 |
+
) -> float:
|
| 59 |
+
"""Calculate peak matching score between two spectra"""
|
| 60 |
+
try:
|
| 61 |
+
# Find peaks in both spectra
|
| 62 |
+
peaks1, _ = find_peaks(spectrum1, height=height_threshold, distance=distance)
|
| 63 |
+
peaks2, _ = find_peaks(spectrum2, height=height_threshold, distance=distance)
|
| 64 |
+
|
| 65 |
+
if len(peaks1) == 0 or len(peaks2) == 0:
|
| 66 |
+
return 0.0
|
| 67 |
+
|
| 68 |
+
# Calculate matching peaks (within tolerance)
|
| 69 |
+
tolerance = 3 # wavenumber tolerance
|
| 70 |
+
matches = 0
|
| 71 |
+
|
| 72 |
+
for peak1 in peaks1:
|
| 73 |
+
for peak2 in peaks2:
|
| 74 |
+
if abs(peak1 - peak2) <= tolerance:
|
| 75 |
+
matches += 1
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
# Return normalized matching score
|
| 79 |
+
return matches / max(len(peaks1), len(peaks2))
|
| 80 |
+
except:
|
| 81 |
+
return 0.0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def spectral_euclidean_distance(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
| 85 |
+
"""Calculate normalized Euclidean distance between spectra"""
|
| 86 |
+
try:
|
| 87 |
+
distance = euclidean(y_true.flatten(), y_pred.flatten())
|
| 88 |
+
# Normalize by the length of the spectrum
|
| 89 |
+
return distance / len(y_true.flatten())
|
| 90 |
+
except:
|
| 91 |
+
return float("inf")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def calculate_spectroscopy_metrics(
|
| 95 |
+
y_true: np.ndarray, y_pred: np.ndarray, probabilities: Optional[np.ndarray] = None
|
| 96 |
+
) -> Dict[str, float]:
|
| 97 |
+
"""Calculate comprehensive spectroscopy-specific metrics"""
|
| 98 |
+
metrics = {}
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
# Standard classification metrics
|
| 102 |
+
metrics["accuracy"] = accuracy_score(y_true, y_pred)
|
| 103 |
+
metrics["f1_score"] = f1_score(y_true, y_pred, average="weighted")
|
| 104 |
+
|
| 105 |
+
# Spectroscopy-specific metrics
|
| 106 |
+
if probabilities is not None and len(probabilities.shape) > 1:
|
| 107 |
+
# For classification with probabilities, use cosine similarity on prob distributions
|
| 108 |
+
unique_classes = np.unique(y_true)
|
| 109 |
+
if len(unique_classes) > 1:
|
| 110 |
+
# Convert true labels to one-hot for similarity calculation
|
| 111 |
+
y_true_onehot = np.eye(len(unique_classes))[y_true]
|
| 112 |
+
metrics["cosine_similarity"] = float(
|
| 113 |
+
cosine_similarity(
|
| 114 |
+
y_true_onehot.mean(axis=0).reshape(1, -1),
|
| 115 |
+
probabilities.mean(axis=0).reshape(1, -1),
|
| 116 |
+
)[0, 0]
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Add bias audit metric (class distribution comparison)
|
| 120 |
+
unique_true, counts_true = np.unique(y_true, return_counts=True)
|
| 121 |
+
unique_pred, counts_pred = np.unique(y_pred, return_counts=True)
|
| 122 |
+
|
| 123 |
+
# Calculate distribution difference (Jensen-Shannon divergence approximation)
|
| 124 |
+
true_dist = counts_true / len(y_true)
|
| 125 |
+
pred_dist = np.zeros_like(true_dist)
|
| 126 |
+
|
| 127 |
+
for i, class_label in enumerate(unique_true):
|
| 128 |
+
if class_label in unique_pred:
|
| 129 |
+
pred_idx = np.where(unique_pred == class_label)[0][0]
|
| 130 |
+
pred_dist[i] = counts_pred[pred_idx] / len(y_pred)
|
| 131 |
+
|
| 132 |
+
# Simple distribution similarity (1 - average absolute difference)
|
| 133 |
+
metrics["distribution_similarity"] = 1.0 - np.mean(
|
| 134 |
+
np.abs(true_dist - pred_dist)
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f"Error calculating spectroscopy metrics: {e}")
|
| 139 |
+
# Return basic metrics
|
| 140 |
+
metrics = {
|
| 141 |
+
"accuracy": accuracy_score(y_true, y_pred) if len(y_true) > 0 else 0.0,
|
| 142 |
+
"f1_score": (
|
| 143 |
+
f1_score(y_true, y_pred, average="weighted") if len(y_true) > 0 else 0.0
|
| 144 |
+
),
|
| 145 |
+
"cosine_similarity": 0.0,
|
| 146 |
+
"distribution_similarity": 0.0,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return metrics
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@dataclass
|
| 153 |
+
class AugmentationConfig:
|
| 154 |
+
"""Data augmentation configuration"""
|
| 155 |
+
|
| 156 |
+
enable_augmentation: bool = False
|
| 157 |
+
noise_level: float = 0.01 # Noise level for augmentation
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@dataclass
|
| 161 |
+
class PreprocessingConfig:
|
| 162 |
+
"""Preprocessing configuration"""
|
| 163 |
+
|
| 164 |
+
baseline_correction: bool = True
|
| 165 |
+
smoothing: bool = True
|
| 166 |
+
normalization: bool = True
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@dataclass
|
| 170 |
+
class TrainingConfig:
|
| 171 |
+
"""Training configuration parameters"""
|
| 172 |
+
|
| 173 |
+
model_name: str
|
| 174 |
+
dataset_path: str
|
| 175 |
+
target_len: int = 500
|
| 176 |
+
batch_size: int = 16
|
| 177 |
+
epochs: int = 10
|
| 178 |
+
learning_rate: float = 1e-3
|
| 179 |
+
num_folds: int = 10
|
| 180 |
+
modality: str = "raman"
|
| 181 |
+
device: str = "auto" # auto, cpu, cuda
|
| 182 |
+
cv_strategy: str = "stratified_kfold" # New field for CV strategy
|
| 183 |
+
spectral_weight: float = 0.1 # Weight for spectroscopy-specific metrics
|
| 184 |
+
augmentation: AugmentationConfig = field(default_factory=AugmentationConfig)
|
| 185 |
+
preprocessing: PreprocessingConfig = field(default_factory=PreprocessingConfig)
|
| 186 |
+
|
| 187 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 188 |
+
"""Convert to dictionary for serialization"""
|
| 189 |
+
return asdict(self)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@dataclass
|
| 193 |
+
class TrainingProgress:
|
| 194 |
+
"""Training progress tracking with enhanced metrics"""
|
| 195 |
+
|
| 196 |
+
current_fold: int = 0
|
| 197 |
+
total_folds: int = 10
|
| 198 |
+
current_epoch: int = 0
|
| 199 |
+
total_epochs: int = 10
|
| 200 |
+
current_loss: float = 0.0
|
| 201 |
+
current_accuracy: float = 0.0
|
| 202 |
+
fold_accuracies: List[float] = field(default_factory=list)
|
| 203 |
+
confusion_matrices: List[List[List[int]]] = field(default_factory=list)
|
| 204 |
+
spectroscopy_metrics: List[Dict[str, float]] = field(default_factory=list)
|
| 205 |
+
start_time: Optional[datetime] = None
|
| 206 |
+
end_time: Optional[datetime] = None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@dataclass
|
| 210 |
+
class TrainingJob:
|
| 211 |
+
"""Training job container"""
|
| 212 |
+
|
| 213 |
+
job_id: str
|
| 214 |
+
config: TrainingConfig
|
| 215 |
+
status: TrainingStatus = TrainingStatus.PENDING
|
| 216 |
+
progress: TrainingProgress = None
|
| 217 |
+
error_message: Optional[str] = None
|
| 218 |
+
created_at: datetime = None
|
| 219 |
+
started_at: Optional[datetime] = None
|
| 220 |
+
completed_at: Optional[datetime] = None
|
| 221 |
+
weights_path: Optional[str] = None
|
| 222 |
+
logs_path: Optional[str] = None
|
| 223 |
+
|
| 224 |
+
def __post_init__(self):
|
| 225 |
+
if self.progress is None:
|
| 226 |
+
self.progress = TrainingProgress(
|
| 227 |
+
total_folds=self.config.num_folds, total_epochs=self.config.epochs
|
| 228 |
+
)
|
| 229 |
+
if self.created_at is None:
|
| 230 |
+
self.created_at = datetime.now()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class TrainingManager:
|
| 234 |
+
"""Manager for training jobs with async execution and progress tracking"""
|
| 235 |
+
|
| 236 |
+
def __init__(
|
| 237 |
+
self,
|
| 238 |
+
max_workers: int = 2,
|
| 239 |
+
output_dir: str = "outputs",
|
| 240 |
+
use_multiprocessing: bool = True,
|
| 241 |
+
):
|
| 242 |
+
self.max_workers = max_workers
|
| 243 |
+
self.use_multiprocessing = use_multiprocessing
|
| 244 |
+
|
| 245 |
+
# Use ProcessPoolExecutor for CPU/GPU-bound tasks, ThreadPoolExecutor for I/O-bound
|
| 246 |
+
if use_multiprocessing:
|
| 247 |
+
# Limit workers to available CPU cores to prevent oversubscription
|
| 248 |
+
actual_workers = min(max_workers, multiprocessing.cpu_count())
|
| 249 |
+
self.executor = concurrent.futures.ProcessPoolExecutor(
|
| 250 |
+
max_workers=actual_workers
|
| 251 |
+
)
|
| 252 |
+
else:
|
| 253 |
+
self.executor = concurrent.futures.ThreadPoolExecutor(
|
| 254 |
+
max_workers=max_workers
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
self.jobs: Dict[str, TrainingJob] = {}
|
| 258 |
+
self.output_dir = Path(output_dir)
|
| 259 |
+
self.output_dir.mkdir(exist_ok=True)
|
| 260 |
+
(self.output_dir / "weights").mkdir(exist_ok=True)
|
| 261 |
+
|
| 262 |
+
def generate_job_id(self) -> str:
|
| 263 |
+
"""Generate unique job ID"""
|
| 264 |
+
return f"train_{uuid.uuid4().hex[:8]}_{int(time.time())}"
|
| 265 |
+
|
| 266 |
+
def submit_training_job(
|
| 267 |
+
self, config: TrainingConfig, progress_callback: Optional[Callable] = None
|
| 268 |
+
) -> str:
|
| 269 |
+
"""Submit a new training job"""
|
| 270 |
+
job_id = self.generate_job_id()
|
| 271 |
+
job = TrainingJob(job_id=job_id, config=config)
|
| 272 |
+
|
| 273 |
+
self.jobs[job_id] = job
|
| 274 |
+
|
| 275 |
+
# Submit to thread pool
|
| 276 |
+
self.executor.submit(
|
| 277 |
+
self._run_training_job, job, progress_callback=progress_callback
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
return job_id
|
| 281 |
+
|
| 282 |
+
def _run_training_job(self, job: TrainingJob) -> None:
|
| 283 |
+
"""Execute training job (runs in separate thread)"""
|
| 284 |
+
try:
|
| 285 |
+
job.status = TrainingStatus.RUNNING
|
| 286 |
+
job.started_at = datetime.now()
|
| 287 |
+
if job.progress:
|
| 288 |
+
job.progress.start_time = job.started_at
|
| 289 |
+
|
| 290 |
+
if progress_callback:
|
| 291 |
+
progress_callback(job)
|
| 292 |
+
|
| 293 |
+
# Load and preprocess data
|
| 294 |
+
X, y = self._load_and_preprocess_data(job)
|
| 295 |
+
if X is None or y is None:
|
| 296 |
+
raise ValueError("Failed to load dataset")
|
| 297 |
+
|
| 298 |
+
# Define a callback to update the job's progress object
|
| 299 |
+
def engine_progress_callback(progress_data: dict):
|
| 300 |
+
if job.progress:
|
| 301 |
+
if progress_data["type"] == "fold_start":
|
| 302 |
+
job.progress.current_fold = progress_data["fold"]
|
| 303 |
+
elif progress_data["type"] == "epoch_end":
|
| 304 |
+
job.progress.current_epoch = progress_data["epoch"]
|
| 305 |
+
job.progress.current_loss = progress_data["loss"]
|
| 306 |
+
if progress_callback:
|
| 307 |
+
progress_callback(job)
|
| 308 |
+
|
| 309 |
+
# Instantiate and run the training engine
|
| 310 |
+
engine = TrainingEngine(job.config)
|
| 311 |
+
results = engine.run(X, y, progress_callback=engine_progress_callback)
|
| 312 |
+
|
| 313 |
+
# Update job with results
|
| 314 |
+
if job.progress:
|
| 315 |
+
job.progress.fold_accuracies = results["fold_accuracies"]
|
| 316 |
+
job.progress.confusion_matrices = results["confusion_matrices"]
|
| 317 |
+
|
| 318 |
+
# Save model weights and logs
|
| 319 |
+
self._save_model_weights(job, results["model_state_dict"])
|
| 320 |
+
self._save_training_results(job)
|
| 321 |
+
|
| 322 |
+
job.status = TrainingStatus.COMPLETED
|
| 323 |
+
job.completed_at = datetime.now()
|
| 324 |
+
job.progress.end_time = job.completed_at
|
| 325 |
+
|
| 326 |
+
except Exception as e:
|
| 327 |
+
job.status = TrainingStatus.FAILED
|
| 328 |
+
job.error_message = str(e)
|
| 329 |
+
job.completed_at = datetime.now()
|
| 330 |
+
|
| 331 |
+
finally:
|
| 332 |
+
if progress_callback:
|
| 333 |
+
progress_callback(job)
|
| 334 |
+
|
| 335 |
+
def _load_and_preprocess_data(
|
| 336 |
+
self, job: TrainingJob
|
| 337 |
+
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
|
| 338 |
+
"""Load and preprocess dataset with enhanced validation and security"""
|
| 339 |
+
try:
|
| 340 |
+
config = job.config
|
| 341 |
+
dataset_path = Path(config.dataset_path)
|
| 342 |
+
|
| 343 |
+
# Enhanced path validation and security
|
| 344 |
+
if not dataset_path.exists():
|
| 345 |
+
raise FileNotFoundError(f"Dataset path not found: {dataset_path}")
|
| 346 |
+
|
| 347 |
+
# Validate dataset path is within allowed directories (security)
|
| 348 |
+
try:
|
| 349 |
+
dataset_path = dataset_path.resolve()
|
| 350 |
+
allowed_bases = [
|
| 351 |
+
Path("datasets").resolve(),
|
| 352 |
+
Path("data").resolve(),
|
| 353 |
+
Path("/tmp").resolve(),
|
| 354 |
+
]
|
| 355 |
+
if not any(
|
| 356 |
+
str(dataset_path).startswith(str(base)) for base in allowed_bases
|
| 357 |
+
):
|
| 358 |
+
raise ValueError(
|
| 359 |
+
f"Dataset path outside allowed directories: {dataset_path}"
|
| 360 |
+
)
|
| 361 |
+
except Exception as e:
|
| 362 |
+
print(f"Path validation error: {e}")
|
| 363 |
+
raise ValueError("Invalid dataset path")
|
| 364 |
+
|
| 365 |
+
# Load data from dataset directory
|
| 366 |
+
X, y = [], []
|
| 367 |
+
total_files = 0
|
| 368 |
+
processed_files = 0
|
| 369 |
+
max_files_per_class = 1000 # Limit to prevent memory issues
|
| 370 |
+
max_file_size = 10 * 1024 * 1024 # 10MB per file
|
| 371 |
+
|
| 372 |
+
# Look for data files in the dataset directory
|
| 373 |
+
for label_dir in dataset_path.iterdir():
|
| 374 |
+
if not label_dir.is_dir():
|
| 375 |
+
continue
|
| 376 |
+
|
| 377 |
+
label = 0 if "stable" in label_dir.name.lower() else 1
|
| 378 |
+
files_in_class = 0
|
| 379 |
+
|
| 380 |
+
# Support multiple file formats
|
| 381 |
+
file_patterns = ["*.txt", "*.csv", "*.json"]
|
| 382 |
+
|
| 383 |
+
for pattern in file_patterns:
|
| 384 |
+
for file_path in label_dir.glob(pattern):
|
| 385 |
+
total_files += 1
|
| 386 |
+
|
| 387 |
+
# Security: Check file size
|
| 388 |
+
if file_path.stat().st_size > max_file_size:
|
| 389 |
+
print(
|
| 390 |
+
f"Skipping large file: {file_path} ({file_path.stat().st_size} bytes)"
|
| 391 |
+
)
|
| 392 |
+
continue
|
| 393 |
+
|
| 394 |
+
# Limit files per class
|
| 395 |
+
if files_in_class >= max_files_per_class:
|
| 396 |
+
print(
|
| 397 |
+
f"Reached maximum files per class ({max_files_per_class}) for {label_dir.name}"
|
| 398 |
+
)
|
| 399 |
+
break
|
| 400 |
+
|
| 401 |
+
try:
|
| 402 |
+
# Load spectrum data based on file type
|
| 403 |
+
if file_path.suffix.lower() == ".txt":
|
| 404 |
+
data = np.loadtxt(file_path)
|
| 405 |
+
if data.ndim == 2 and data.shape[1] >= 2:
|
| 406 |
+
x_raw, y_raw = data[:, 0], data[:, 1]
|
| 407 |
+
elif data.ndim == 1:
|
| 408 |
+
# Single column data
|
| 409 |
+
x_raw = np.arange(len(data))
|
| 410 |
+
y_raw = data
|
| 411 |
+
else:
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
elif file_path.suffix.lower() == ".csv":
|
| 415 |
+
import pandas as pd
|
| 416 |
+
|
| 417 |
+
df = pd.read_csv(file_path)
|
| 418 |
+
if df.shape[1] >= 2:
|
| 419 |
+
x_raw, y_raw = (
|
| 420 |
+
df.iloc[:, 0].values,
|
| 421 |
+
df.iloc[:, 1].values,
|
| 422 |
+
)
|
| 423 |
+
else:
|
| 424 |
+
x_raw = np.arange(len(df))
|
| 425 |
+
y_raw = df.iloc[:, 0].values
|
| 426 |
+
|
| 427 |
+
elif file_path.suffix.lower() == ".json":
|
| 428 |
+
with open(file_path, "r") as f:
|
| 429 |
+
data_dict = json.load(f)
|
| 430 |
+
if isinstance(data_dict, dict):
|
| 431 |
+
if "x" in data_dict and "y" in data_dict:
|
| 432 |
+
x_raw, y_raw = np.array(
|
| 433 |
+
data_dict["x"]
|
| 434 |
+
), np.array(data_dict["y"])
|
| 435 |
+
elif "spectrum" in data_dict:
|
| 436 |
+
y_raw = np.array(data_dict["spectrum"])
|
| 437 |
+
x_raw = np.arange(len(y_raw))
|
| 438 |
+
else:
|
| 439 |
+
continue
|
| 440 |
+
else:
|
| 441 |
+
continue
|
| 442 |
+
else:
|
| 443 |
+
continue
|
| 444 |
+
|
| 445 |
+
# Validate data integrity
|
| 446 |
+
if len(x_raw) != len(y_raw) or len(x_raw) < 10:
|
| 447 |
+
print(
|
| 448 |
+
f"Invalid data in file {file_path}: insufficient data points"
|
| 449 |
+
)
|
| 450 |
+
continue
|
| 451 |
+
|
| 452 |
+
# Check for NaN or infinite values
|
| 453 |
+
if np.any(np.isnan(y_raw)) or np.any(np.isinf(y_raw)):
|
| 454 |
+
print(
|
| 455 |
+
f"Invalid data in file {file_path}: NaN or infinite values"
|
| 456 |
+
)
|
| 457 |
+
continue
|
| 458 |
+
|
| 459 |
+
# Validate reasonable value ranges for spectroscopy
|
| 460 |
+
if np.min(y_raw) < -1000 or np.max(y_raw) > 1e6:
|
| 461 |
+
print(
|
| 462 |
+
f"Suspicious data values in file {file_path}: outside expected range"
|
| 463 |
+
)
|
| 464 |
+
continue
|
| 465 |
+
|
| 466 |
+
# Preprocess spectrum
|
| 467 |
+
_, y_processed = preprocess_spectrum(
|
| 468 |
+
x_raw,
|
| 469 |
+
y_raw,
|
| 470 |
+
modality=config.modality,
|
| 471 |
+
target_len=config.target_len,
|
| 472 |
+
do_baseline=config.baseline_correction,
|
| 473 |
+
do_smooth=config.smoothing,
|
| 474 |
+
do_normalize=config.normalization,
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
# Final validation of processed data
|
| 478 |
+
if (
|
| 479 |
+
y_processed is None
|
| 480 |
+
or len(y_processed) != config.target_len
|
| 481 |
+
):
|
| 482 |
+
print(f"Preprocessing failed for file {file_path}")
|
| 483 |
+
continue
|
| 484 |
+
|
| 485 |
+
X.append(y_processed)
|
| 486 |
+
y.append(label)
|
| 487 |
+
files_in_class += 1
|
| 488 |
+
processed_files += 1
|
| 489 |
+
|
| 490 |
+
except Exception as e:
|
| 491 |
+
print(f"Error processing file {file_path}: {e}")
|
| 492 |
+
continue
|
| 493 |
+
|
| 494 |
+
# Validate final dataset
|
| 495 |
+
if len(X) == 0:
|
| 496 |
+
raise ValueError("No valid data files found in dataset")
|
| 497 |
+
|
| 498 |
+
if len(X) < 10:
|
| 499 |
+
raise ValueError(
|
| 500 |
+
f"Insufficient data: only {len(X)} samples found (minimum 10 required)"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
# Check class balance
|
| 504 |
+
unique_labels, counts = np.unique(y, return_counts=True)
|
| 505 |
+
if len(unique_labels) < 2:
|
| 506 |
+
raise ValueError("Dataset must contain at least 2 classes")
|
| 507 |
+
|
| 508 |
+
min_class_size = min(counts)
|
| 509 |
+
if min_class_size < 3:
|
| 510 |
+
raise ValueError(
|
| 511 |
+
f"Insufficient samples in one class: minimum {min_class_size} (need at least 3)"
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
print(f"Dataset loaded: {processed_files}/{total_files} files processed")
|
| 515 |
+
print(f"Class distribution: {dict(zip(unique_labels, counts))}")
|
| 516 |
+
|
| 517 |
+
return np.array(X, dtype=np.float32), np.array(y, dtype=np.int64)
|
| 518 |
+
|
| 519 |
+
except Exception as e:
|
| 520 |
+
print(f"Error loading dataset: {e}")
|
| 521 |
+
return None, None
|
| 522 |
+
|
| 523 |
+
def _save_model_weights(self, job: TrainingJob, model_state_dict: dict):
|
| 524 |
+
"""Saves the model's state dictionary to a file."""
|
| 525 |
+
weights_dir = self.output_dir / "weights"
|
| 526 |
+
weights_dir.mkdir(exist_ok=True)
|
| 527 |
+
job.weights_path = str(weights_dir / f"{job.config.model_name}_model.pth")
|
| 528 |
+
torch.save(model_state_dict, job.weights_path)
|
| 529 |
+
|
| 530 |
+
def _save_training_results(self, job: TrainingJob):
|
| 531 |
+
"""Save training results and logs with enhanced metrics"""
|
| 532 |
+
logs_dir = self.output_dir / "logs"
|
| 533 |
+
logs_dir.mkdir(exist_ok=True)
|
| 534 |
+
job.logs_path = str(logs_dir / f"{job.job_id}_log.json")
|
| 535 |
+
|
| 536 |
+
# Calculate comprehensive summary metrics
|
| 537 |
+
spectro_summary = {}
|
| 538 |
+
if job.progress.spectroscopy_metrics:
|
| 539 |
+
# Average across all folds for each metric
|
| 540 |
+
metric_keys = job.progress.spectroscopy_metrics[0].keys()
|
| 541 |
+
for key in metric_keys:
|
| 542 |
+
values = [
|
| 543 |
+
fold_metrics.get(key, 0.0)
|
| 544 |
+
for fold_metrics in job.progress.spectroscopy_metrics
|
| 545 |
+
]
|
| 546 |
+
spectro_summary[f"mean_{key}"] = float(np.mean(values))
|
| 547 |
+
spectro_summary[f"std_{key}"] = float(np.std(values))
|
| 548 |
+
|
| 549 |
+
results = {
|
| 550 |
+
"job_id": job.job_id,
|
| 551 |
+
"config": job.config.to_dict(),
|
| 552 |
+
"status": job.status.value,
|
| 553 |
+
"created_at": job.created_at.isoformat(),
|
| 554 |
+
"started_at": job.started_at.isoformat() if job.started_at else None,
|
| 555 |
+
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
|
| 556 |
+
"progress": {
|
| 557 |
+
"fold_accuracies": job.progress.fold_accuracies,
|
| 558 |
+
"confusion_matrices": job.progress.confusion_matrices,
|
| 559 |
+
"spectroscopy_metrics": job.progress.spectroscopy_metrics,
|
| 560 |
+
"mean_accuracy": (
|
| 561 |
+
np.mean(job.progress.fold_accuracies)
|
| 562 |
+
if job.progress.fold_accuracies
|
| 563 |
+
else 0.0
|
| 564 |
+
),
|
| 565 |
+
"std_accuracy": (
|
| 566 |
+
np.std(job.progress.fold_accuracies)
|
| 567 |
+
if job.progress.fold_accuracies
|
| 568 |
+
else 0.0
|
| 569 |
+
),
|
| 570 |
+
"spectroscopy_summary": spectro_summary,
|
| 571 |
+
},
|
| 572 |
+
"weights_path": job.weights_path,
|
| 573 |
+
"error_message": job.error_message,
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
if job.logs_path:
|
| 577 |
+
with open(job.logs_path, "w") as f:
|
| 578 |
+
json.dump(results, f, indent=2)
|
| 579 |
+
|
| 580 |
+
def get_job_status(self, job_id: str) -> Optional[TrainingJob]:
|
| 581 |
+
"""Get current status of a training job"""
|
| 582 |
+
return self.jobs.get(job_id)
|
| 583 |
+
|
| 584 |
+
def list_jobs(
|
| 585 |
+
self, status_filter: Optional[TrainingStatus] = None
|
| 586 |
+
) -> List[TrainingJob]:
|
| 587 |
+
"""List all jobs, optionally filtered by status"""
|
| 588 |
+
jobs = list(self.jobs.values())
|
| 589 |
+
if status_filter:
|
| 590 |
+
jobs = [job for job in jobs if job.status == status_filter]
|
| 591 |
+
return sorted(jobs, key=lambda j: j.created_at, reverse=True)
|
| 592 |
+
|
| 593 |
+
def cancel_job(self, job_id: str) -> bool:
|
| 594 |
+
"""Cancel a running job"""
|
| 595 |
+
job = self.jobs.get(job_id)
|
| 596 |
+
if job and job.status == TrainingStatus.RUNNING:
|
| 597 |
+
job.status = TrainingStatus.CANCELLED
|
| 598 |
+
job.completed_at = datetime.now()
|
| 599 |
+
# Note: This is a simple cancellation - actual thread termination is more complex
|
| 600 |
+
return True
|
| 601 |
+
return False
|
| 602 |
+
|
| 603 |
+
def cleanup_old_jobs(self, max_age_hours: int = 24):
|
| 604 |
+
"""Clean up old completed/failed jobs"""
|
| 605 |
+
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
|
| 606 |
+
to_remove = []
|
| 607 |
+
|
| 608 |
+
for job_id, job in self.jobs.items():
|
| 609 |
+
if (
|
| 610 |
+
job.status
|
| 611 |
+
in [
|
| 612 |
+
TrainingStatus.COMPLETED,
|
| 613 |
+
TrainingStatus.FAILED,
|
| 614 |
+
TrainingStatus.CANCELLED,
|
| 615 |
+
]
|
| 616 |
+
and job.completed_at
|
| 617 |
+
and job.completed_at < cutoff_time
|
| 618 |
+
):
|
| 619 |
+
to_remove.append(job_id)
|
| 620 |
+
|
| 621 |
+
for job_id in to_remove:
|
| 622 |
+
del self.jobs[job_id]
|
| 623 |
+
|
| 624 |
+
def shutdown(self):
|
| 625 |
+
"""Shutdown the training manager"""
|
| 626 |
+
self.executor.shutdown(wait=True)
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
# Global training manager instance
|
| 630 |
+
_training_manager = None
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def get_training_manager() -> TrainingManager:
|
| 634 |
+
"""Get global training manager instance"""
|
| 635 |
+
global _training_manager
|
| 636 |
+
if _training_manager is None:
|
| 637 |
+
_training_manager = TrainingManager()
|
| 638 |
+
return _training_manager
|
backend/utils/training_types.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Defines core data structures and types for the training system.
|
| 3 |
+
|
| 4 |
+
This module centralizes data classes like TrainingConfig and helper
|
| 5 |
+
functions to avoid circular dependencies between the TrainingManager
|
| 6 |
+
and TrainingEngine.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, asdict, field
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import List, Optional, Dict, Any, Tuple
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from sklearn.model_selection import StratifiedKFold, KFold, TimeSeriesSplit
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TrainingStatus(Enum):
|
| 19 |
+
"""Training job status enumeration"""
|
| 20 |
+
|
| 21 |
+
PENDING = "pending"
|
| 22 |
+
RUNNING = "running"
|
| 23 |
+
COMPLETED = "completed"
|
| 24 |
+
FAILED = "failed"
|
| 25 |
+
CANCELLED = "cancelled"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CVStrategy(Enum):
|
| 29 |
+
"""Cross-validation strategy enumeration"""
|
| 30 |
+
|
| 31 |
+
STRATIFIED_KFOLD = "stratified_kfold"
|
| 32 |
+
KFOLD = "kfold"
|
| 33 |
+
TIME_SERIES_SPLIT = "time_series_split"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class TrainingConfig:
|
| 38 |
+
"""Training configuration parameters"""
|
| 39 |
+
|
| 40 |
+
model_name: str
|
| 41 |
+
dataset_path: str
|
| 42 |
+
target_len: int = 500
|
| 43 |
+
batch_size: int = 16
|
| 44 |
+
epochs: int = 10
|
| 45 |
+
learning_rate: float = 1e-3
|
| 46 |
+
num_folds: int = 10
|
| 47 |
+
baseline_correction: bool = True
|
| 48 |
+
smoothing: bool = True
|
| 49 |
+
normalization: bool = True
|
| 50 |
+
modality: str = "raman"
|
| 51 |
+
device: str = "auto" # auto, cpu, cuda
|
| 52 |
+
cv_strategy: str = "stratified_kfold" # New field for CV strategy
|
| 53 |
+
spectral_weight: float = 0.1 # Weight for spectroscopy-specific metrics
|
| 54 |
+
enable_augmentation: bool = False # Enable data augmentation
|
| 55 |
+
noise_level: float = 0.01 # Noise level for augmentation
|
| 56 |
+
|
| 57 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 58 |
+
"""Convert to dictionary for serialization"""
|
| 59 |
+
return asdict(self)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class TrainingProgress:
|
| 64 |
+
"""Training progress tracking with enhanced metrics"""
|
| 65 |
+
|
| 66 |
+
current_fold: int = 0
|
| 67 |
+
total_folds: int = 10
|
| 68 |
+
current_epoch: int = 0
|
| 69 |
+
total_epochs: int = 10
|
| 70 |
+
current_loss: float = 0.0
|
| 71 |
+
current_accuracy: float = 0.0
|
| 72 |
+
fold_accuracies: List[float] = field(default_factory=list)
|
| 73 |
+
confusion_matrices: List[List[List[int]]] = field(default_factory=list)
|
| 74 |
+
spectroscopy_metrics: List[Dict[str, float]] = field(default_factory=list)
|
| 75 |
+
start_time: Optional[datetime] = None
|
| 76 |
+
end_time: Optional[datetime] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_cv_splitter(strategy: str, n_splits: int = 10, random_state: int = 42):
|
| 80 |
+
"""Get cross-validation splitter based on strategy"""
|
| 81 |
+
if strategy == "stratified_kfold":
|
| 82 |
+
return StratifiedKFold(
|
| 83 |
+
n_splits=n_splits, shuffle=True, random_state=random_state
|
| 84 |
+
)
|
| 85 |
+
elif strategy == "kfold":
|
| 86 |
+
return KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
|
| 87 |
+
elif strategy == "time_series_split":
|
| 88 |
+
return TimeSeriesSplit(n_splits=n_splits)
|
| 89 |
+
else:
|
| 90 |
+
# Default to stratified k-fold
|
| 91 |
+
return StratifiedKFold(
|
| 92 |
+
n_splits=n_splits, shuffle=True, random_state=random_state
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def augment_spectral_data(
|
| 97 |
+
X: np.ndarray,
|
| 98 |
+
y: np.ndarray,
|
| 99 |
+
noise_level: float = 0.01,
|
| 100 |
+
augmentation_factor: int = 2,
|
| 101 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 102 |
+
"""Augment spectral data with realistic noise and variations"""
|
| 103 |
+
if augmentation_factor <= 1:
|
| 104 |
+
return X, y
|
| 105 |
+
|
| 106 |
+
augmented_X = [X]
|
| 107 |
+
augmented_y = [y]
|
| 108 |
+
|
| 109 |
+
for i in range(augmentation_factor - 1):
|
| 110 |
+
# Add Gaussian noise
|
| 111 |
+
noise = np.random.normal(0, noise_level, X.shape)
|
| 112 |
+
X_noisy = X + noise
|
| 113 |
+
|
| 114 |
+
# Add baseline drift (common in spectroscopy)
|
| 115 |
+
baseline_drift = np.random.normal(0, noise_level * 0.5, (X.shape[0], 1))
|
| 116 |
+
X_drift = X_noisy + baseline_drift
|
| 117 |
+
|
| 118 |
+
# Add intensity scaling variation
|
| 119 |
+
intensity_scale = np.random.normal(1.0, 0.05, (X.shape[0], 1))
|
| 120 |
+
X_scaled = X_drift * intensity_scale
|
| 121 |
+
|
| 122 |
+
# Ensure no negative values
|
| 123 |
+
X_scaled = np.maximum(X_scaled, 0)
|
| 124 |
+
|
| 125 |
+
augmented_X.append(X_scaled)
|
| 126 |
+
augmented_y.append(y)
|
| 127 |
+
|
| 128 |
+
return np.vstack(augmented_X), np.hstack(augmented_y)
|
frontend/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend React Application
|
| 2 |
+
|
| 3 |
+
This directory contains the React/TypeScript frontend for the Polymer Aging ML application.
|
| 4 |
+
|
| 5 |
+
## Architecture
|
| 6 |
+
|
| 7 |
+
The frontend is a modern React application with TypeScript:
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
frontend/
|
| 11 |
+
├── src/
|
| 12 |
+
│ ├── components/ # React components
|
| 13 |
+
│ │ ├── Header.tsx
|
| 14 |
+
│ │ ├── SpectrumChart.tsx
|
| 15 |
+
│ │ ├── ResultsDisplay.tsx
|
| 16 |
+
│ │ └── ...
|
| 17 |
+
│ ├── apiClient.ts # Centralized API client
|
| 18 |
+
│ ├── types/
|
| 19 |
+
│ │ ├── api.ts # Auto-generated API types
|
| 20 |
+
│ │ └── index.ts # Custom types
|
| 21 |
+
│ ├── App.tsx # Main application component
|
| 22 |
+
│ └── index.tsx # Application entry point
|
| 23 |
+
├── public/ # Static assets
|
| 24 |
+
├── package.json # Dependencies and scripts
|
| 25 |
+
└── tsconfig.json # TypeScript configuration
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Key Features
|
| 29 |
+
|
| 30 |
+
- **Type Safety**: Full TypeScript integration with OpenAPI-generated types
|
| 31 |
+
- **Centralized API Client**: Single source for all backend communication
|
| 32 |
+
- **Component Architecture**: Modular, reusable React components
|
| 33 |
+
- **Responsive Design**: Works across desktop and mobile devices
|
| 34 |
+
- **Error Handling**: Graceful error handling with user feedback
|
| 35 |
+
|
| 36 |
+
## Setup and Development
|
| 37 |
+
|
| 38 |
+
### Prerequisites
|
| 39 |
+
|
| 40 |
+
- Node.js 16+ and npm 8+
|
| 41 |
+
- Backend API server running (for development)
|
| 42 |
+
|
| 43 |
+
### Installation
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
# Install dependencies
|
| 47 |
+
npm install --legacy-peer-deps
|
| 48 |
+
|
| 49 |
+
# Verify TypeScript types are up to date
|
| 50 |
+
npm run typegen:file
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Development Server
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
# Start development server with hot reload
|
| 57 |
+
npm start
|
| 58 |
+
|
| 59 |
+
# Opens http://localhost:3000
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### Build for Production
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# Create production build
|
| 66 |
+
npm run build
|
| 67 |
+
|
| 68 |
+
# Build files output to build/ directory
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## API Integration
|
| 72 |
+
|
| 73 |
+
### Centralized API Client
|
| 74 |
+
|
| 75 |
+
All backend communication goes through `src/apiClient.ts`:
|
| 76 |
+
|
| 77 |
+
```typescript
|
| 78 |
+
import { ApiClient } from './apiClient';
|
| 79 |
+
|
| 80 |
+
const api = new ApiClient('http://localhost:8000');
|
| 81 |
+
|
| 82 |
+
// Example usage
|
| 83 |
+
const result = await api.analyzeSpectrum({
|
| 84 |
+
spectrum: spectrumData,
|
| 85 |
+
model_name: 'resnet',
|
| 86 |
+
modality: 'raman'
|
| 87 |
+
});
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### Type Generation
|
| 91 |
+
|
| 92 |
+
API types are automatically generated from the OpenAPI schema:
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
# Generate types from running backend
|
| 96 |
+
npm run typegen
|
| 97 |
+
|
| 98 |
+
# Generate types from schema file
|
| 99 |
+
npm run typegen:file
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Scripts
|
| 103 |
+
|
| 104 |
+
Available npm scripts:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
npm start # Development server
|
| 108 |
+
npm run build # Production build
|
| 109 |
+
npm test # Run tests
|
| 110 |
+
npm run lint # ESLint checking
|
| 111 |
+
npm run format # Prettier formatting
|
| 112 |
+
npm run typegen # Generate API types
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## API Contract Adherence
|
| 116 |
+
|
| 117 |
+
The frontend strictly adheres to the OpenAPI contract:
|
| 118 |
+
- All requests/responses validated by TypeScript types
|
| 119 |
+
- Automatic type generation ensures contract compliance
|
| 120 |
+
- No direct backend imports or cross-boundary dependencies
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "polymer-aging-ml",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Full-stack React/TypeScript project for polymer aging ML, deployable in Docker and Hugging Face Space.",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"start": "react-scripts start",
|
| 7 |
+
"build": "react-scripts build",
|
| 8 |
+
"test": "react-scripts test",
|
| 9 |
+
"eject": "react-scripts eject",
|
| 10 |
+
"typegen": "openapi-typescript http://localhost:8000/api/v1/openapi.json --output src/types/api.ts",
|
| 11 |
+
"typegen:file": "openapi-typescript ../openapi-schema.json --output src/types/api.ts",
|
| 12 |
+
"check-types-sync": "../scripts/check-typegen-sync.sh",
|
| 13 |
+
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
|
| 14 |
+
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
|
| 15 |
+
},
|
| 16 |
+
"keywords": [
|
| 17 |
+
"react",
|
| 18 |
+
"typescript",
|
| 19 |
+
"ml",
|
| 20 |
+
"polymer",
|
| 21 |
+
"huggingface"
|
| 22 |
+
],
|
| 23 |
+
"author": "devjas1",
|
| 24 |
+
"license": "MIT",
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"axios": "^1.5.0",
|
| 27 |
+
"openapi-typescript-codegen": "^0.29.0",
|
| 28 |
+
"react": "^18.2.0",
|
| 29 |
+
"react-dom": "^18.2.0",
|
| 30 |
+
"react-dropzone": "^14.3.8",
|
| 31 |
+
"react-router-dom": "^6.15.0",
|
| 32 |
+
"react-scripts": "^5.0.0",
|
| 33 |
+
"recharts": "^3.2.1",
|
| 34 |
+
"web-vitals": "^5.1.0"
|
| 35 |
+
},
|
| 36 |
+
"devDependencies": {
|
| 37 |
+
"@testing-library/jest-dom": "^6.8.0",
|
| 38 |
+
"@testing-library/react": "^14.3.1",
|
| 39 |
+
"@types/jest": "^30.0.0",
|
| 40 |
+
"@types/react": "^18.0.37",
|
| 41 |
+
"@types/react-dom": "^18.0.11",
|
| 42 |
+
"@types/react-dropzone": "^4.2.2",
|
| 43 |
+
"@types/react-router-dom": "^5.3.3",
|
| 44 |
+
"@types/recharts": "^1.8.29",
|
| 45 |
+
"eslint": "^8.56.0",
|
| 46 |
+
"eslint-config-prettier": "^9.1.0",
|
| 47 |
+
"eslint-plugin-react": "^7.33.2",
|
| 48 |
+
"jest": "^28.1.3",
|
| 49 |
+
"openapi-typescript": "^6.7.0",
|
| 50 |
+
"prettier": "^3.2.2",
|
| 51 |
+
"typescript": "^4.9.5"
|
| 52 |
+
},
|
| 53 |
+
"browserslist": {
|
| 54 |
+
"production": [
|
| 55 |
+
">0.2%",
|
| 56 |
+
"not dead",
|
| 57 |
+
"not op_mini all"
|
| 58 |
+
],
|
| 59 |
+
"development": [
|
| 60 |
+
"last 1 chrome version",
|
| 61 |
+
"last 1 firefox version",
|
| 62 |
+
"last 1 safari version"
|
| 63 |
+
]
|
| 64 |
+
}
|
| 65 |
+
}
|
frontend/public/index.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<meta name="theme-color" content="#000000" />
|
| 8 |
+
<meta
|
| 9 |
+
name="description"
|
| 10 |
+
content="Web site created using create-react-app"
|
| 11 |
+
/>
|
| 12 |
+
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
+
<!--
|
| 14 |
+
manifest.json provides metadata used when your web app is installed on a
|
| 15 |
+
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
| 16 |
+
-->
|
| 17 |
+
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
| 18 |
+
<!--
|
| 19 |
+
Notice the use of %PUBLIC_URL% in the tags above.
|
| 20 |
+
It will be replaced with the URL of the `public` folder during the build.
|
| 21 |
+
Only files inside the `public` folder can be referenced from the HTML.
|
| 22 |
+
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
| 23 |
+
work correctly both with client-side routing and a non-root public URL.
|
| 24 |
+
Learn how to configure a non-root public URL by running `npm run build`.
|
| 25 |
+
-->
|
| 26 |
+
<title>React App</title>
|
| 27 |
+
</head>
|
| 28 |
+
<body>
|
| 29 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 30 |
+
<div id="root"></div>
|
| 31 |
+
<!--
|
| 32 |
+
This HTML file is a template.
|
| 33 |
+
If you open it directly in the browser, you will see an empty page.
|
| 34 |
+
You can add webfonts, meta tags, or analytics to this file.
|
| 35 |
+
The build step will place the bundled scripts into the <body> tag.
|
| 36 |
+
To begin the development, run `npm start` or `yarn start`.
|
| 37 |
+
To create a production bundle, use `npm run build` or `yarn build`.
|
| 38 |
+
-->
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
frontend/public/robots.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
+
User-agent: *
|
| 3 |
+
Disallow:
|