devjas1 commited on
Commit
4a0e21d
·
0 Parent(s):

Initial Release: Polymer Aging With ML [Standalone Appliance]

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +49 -0
  2. .gitattributes +37 -0
  3. .gitignore +59 -0
  4. ARCHITECTURE.md +504 -0
  5. Dockerfile +44 -0
  6. LICENSE +201 -0
  7. README.md +177 -0
  8. backend/__init__.py +1 -0
  9. backend/config.py +4 -0
  10. backend/main.py +598 -0
  11. backend/models/__init__.py +0 -0
  12. backend/models/enhanced_cnn.py +410 -0
  13. backend/models/figure2_cnn.py +77 -0
  14. backend/models/registry.py +246 -0
  15. backend/models/resnet18_vision.py +82 -0
  16. backend/models/resnet_cnn.py +73 -0
  17. backend/models/weights/efficient_cnn_model.pth +3 -0
  18. backend/models/weights/enhanced_cnn_model.pth +3 -0
  19. backend/models/weights/figure2_model.pth +3 -0
  20. backend/models/weights/hybrid_net_model.pth +3 -0
  21. backend/models/weights/resnet18vision_model.pth +3 -0
  22. backend/models/weights/resnet_model.pth +3 -0
  23. backend/pydantic_models.py +353 -0
  24. backend/registry.py +237 -0
  25. backend/service.py +331 -0
  26. backend/service.py # (edit +0 -0
  27. backend/tests/test_api.py +17 -0
  28. backend/tests/test_service.py +54 -0
  29. backend/utils/confidence.py +97 -0
  30. backend/utils/enhanced_ml_service.py +317 -0
  31. backend/utils/errors.py +36 -0
  32. backend/utils/model_manager.py +177 -0
  33. backend/utils/multifile.py +480 -0
  34. backend/utils/performance.py +101 -0
  35. backend/utils/prepare_data.py +76 -0
  36. backend/utils/preprocessing.py +331 -0
  37. backend/utils/preprocessing_fixed.py +301 -0
  38. backend/utils/raman_util.py +46 -0
  39. backend/utils/seeds.py +157 -0
  40. backend/utils/spectrum_preprocessor.py +242 -0
  41. backend/utils/train.py +163 -0
  42. backend/utils/training_engine.py +161 -0
  43. backend/utils/training_engine_enhanced.py +431 -0
  44. backend/utils/training_manager.py +638 -0
  45. backend/utils/training_types.py +128 -0
  46. frontend/README.md +120 -0
  47. frontend/package-lock.json +0 -0
  48. frontend/package.json +65 -0
  49. frontend/public/index.html +40 -0
  50. 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](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
17
+ ![React](https://img.shields.io/badge/React-18.2-brightgreen)
18
+ ![FastAPI](https://img.shields.io/badge/FastAPI-0.116%2B-blue)
19
+ ![Docker](https://img.shields.io/badge/docker-ready-blue)
20
+ ![HF Space](https://img.shields.io/badge/HF%20Space-Live-blueviolet)
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
+ ![React](https://img.shields.io/badge/React-18.2-brightgreen)
15
+ ![FastAPI](https://img.shields.io/badge/FastAPI-0.116%2B-blue)
16
+ ![PyTorch](https://img.shields.io/badge/PyTorch-ML--Core-red)
17
+ ![Docker](https://img.shields.io/badge/docker-ready-blue)
18
+ ![License](https://img.shields.io/badge/License-Apache%202.0-blue)
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: