JunhanCai commited on
Commit
6918c6b
·
1 Parent(s): 5e1e074

Initial commit with GEMS model and Dockerfile

Browse files
Files changed (46) hide show
  1. main/GEMS.py +242 -0
  2. main/Raman_Task.py +992 -0
  3. main/Ramandataset.py +380 -0
  4. main/__init__.py +0 -0
  5. main/__pycache__/GEMS.cpython-312.pyc +0 -0
  6. main/__pycache__/Raman_Task.cpython-312.pyc +0 -0
  7. main/__pycache__/Ramandataset.cpython-312.pyc +0 -0
  8. main/__pycache__/__init__.cpython-312.pyc +0 -0
  9. main/__pycache__/data_augumentation.cpython-312.pyc +0 -0
  10. main/__pycache__/evaluate_few_shot_models_fixed_test.cpython-312.pyc +0 -0
  11. main/__pycache__/evaluate_visualize.cpython-312.pyc +0 -0
  12. main/__pycache__/hyperpara_optim_contrastive_weight.cpython-312.pyc +0 -0
  13. main/__pycache__/hyperpara_optim_downstream.cpython-312.pyc +0 -0
  14. main/__pycache__/hyperpara_optim_pretrain.cpython-312.pyc +0 -0
  15. main/__pycache__/load_data.cpython-312.pyc +0 -0
  16. main/__pycache__/pretext.cpython-312.pyc +0 -0
  17. main/__pycache__/sample_ig_spectrum_check.cpython-312.pyc +0 -0
  18. main/data_augumentation.py +195 -0
  19. main/evaluate_few_shot_models_fixed_test.py +470 -0
  20. main/evaluate_visualize.py +1270 -0
  21. main/few_shot_bacteria_finetune.py +490 -0
  22. main/few_shot_cir.py +246 -0
  23. main/finetune.py +206 -0
  24. main/hyperpara_optim.py +343 -0
  25. main/hyperpara_optim_contrastive_weight.py +422 -0
  26. main/hyperpara_optim_downstream.py +173 -0
  27. main/hyperpara_optim_pipeline.py +78 -0
  28. main/hyperpara_optim_pretrain.py +236 -0
  29. main/load_data.py +114 -0
  30. main/plot_two_stage_hpo_a4.py +192 -0
  31. main/pretext.py +554 -0
  32. main/requirements.txt +12 -0
  33. webserver/Dockerfile +18 -0
  34. webserver/__pycache__/app.cpython-312.pyc +0 -0
  35. webserver/__pycache__/label_utils.cpython-312.pyc +0 -0
  36. webserver/__pycache__/preprocess_utils.cpython-312.pyc +0 -0
  37. webserver/__pycache__/train_service.cpython-312.pyc +0 -0
  38. webserver/app.py +663 -0
  39. webserver/label_utils.py +65 -0
  40. webserver/preprocess_utils.py +140 -0
  41. webserver/requirements.txt +8 -0
  42. webserver/templates/index.html +487 -0
  43. webserver/templates/predict.html +277 -0
  44. webserver/templates/predict_result_fragment.html +69 -0
  45. webserver/templates/status.html +202 -0
  46. webserver/train_service.py +441 -0
main/GEMS.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import numpy as np
4
+ from timm.models.vision_transformer import Block
5
+
6
+ class PatchEmbed1D(nn.Module):
7
+ def __init__(self, input_length, patch_size=16, in_chans=1, embed_dim=128):
8
+ super().__init__()
9
+ self.patch_size = patch_size
10
+ self.in_chans = in_chans
11
+ self.embed_dim = embed_dim
12
+ self.num_patches = input_length // patch_size
13
+
14
+ self.proj = nn.Conv1d(
15
+ in_channels=in_chans,
16
+ out_channels=embed_dim,
17
+ kernel_size=patch_size,
18
+ stride=patch_size
19
+ )
20
+ def forward(self, x):
21
+ x = self.proj(x) # [batch_size, embed_dim, num_patches]
22
+ x = x.transpose(1, 2) # [batch_size, num_patches, embed_dim]
23
+ return x
24
+
25
+
26
+ def get_1d_sincos_pos_embed(embed_dim, length, cls_token=False):
27
+ assert embed_dim % 2 == 0
28
+ pos = np.arange(length)
29
+ omega = np.arange(embed_dim // 2) / (embed_dim // 2)
30
+ omega = 1. / (10000 ** omega)
31
+ pos = pos.reshape(-1, 1)
32
+ omega = omega.reshape(1, -1)
33
+ pos_omega = pos * omega
34
+ emb_sin = np.sin(pos_omega)
35
+ emb_cos = np.cos(pos_omega)
36
+ emb = np.concatenate([emb_sin, emb_cos], axis=1) # [length, embed_dim]
37
+ if cls_token:
38
+ emb = np.concatenate([np.zeros([1, embed_dim]), emb], axis=0)
39
+ return emb
40
+
41
+
42
+ class MaskedAutoencoderRaman(nn.Module):
43
+ def __init__(self, input_length, patch_num=20,
44
+ embed_dim=1024, depth=12, num_heads=16,
45
+ decoder_embed_dim=512, decoder_depth=4, decoder_num_heads=8,
46
+ mlp_ratio=4., norm_layer=nn.LayerNorm, norm_pix_loss=False):
47
+ super().__init__()
48
+
49
+ self.patch_size = input_length // patch_num
50
+ self.embed_dim = embed_dim
51
+ self.patch_embed = PatchEmbed1D(input_length=input_length,
52
+ patch_size=self.patch_size,
53
+ in_chans=1,
54
+ embed_dim=embed_dim)
55
+ self.stride = self.patch_size // 2
56
+ self.patch_num = (input_length - self.patch_size) // self.stride + 1
57
+
58
+ self.encoder_input = nn.Sequential(
59
+ nn.Linear(self.patch_size, embed_dim),
60
+ nn.GELU(),
61
+ nn.LayerNorm(embed_dim)
62
+ )
63
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
64
+ self.pos_embed = nn.Parameter(torch.zeros(1, self.patch_num + 1, embed_dim), requires_grad=False)
65
+ self.encoder_blocks = nn.ModuleList([
66
+ Block(embed_dim, num_heads, mlp_ratio, qkv_bias=True, norm_layer=norm_layer)
67
+ for i in range(depth)
68
+ ])
69
+ self.encoder_norm = norm_layer(embed_dim)
70
+
71
+ self.decoder_embed = nn.Linear(embed_dim, decoder_embed_dim, bias=True)
72
+ self.mask_token = nn.Parameter(torch.zeros(1, 1, decoder_embed_dim))
73
+ self.decoder_pos_embed = nn.Parameter(torch.zeros(1, self.patch_num + 1, decoder_embed_dim), requires_grad=False)
74
+ self.decoder_blocks = nn.ModuleList([
75
+ Block(decoder_embed_dim, decoder_num_heads, mlp_ratio, qkv_bias=True, norm_layer=norm_layer)
76
+ for i in range(decoder_depth)
77
+ ])
78
+ self.decoder_norm = norm_layer(decoder_embed_dim)
79
+
80
+ self.decoder_pred = nn.Linear(decoder_embed_dim, self.patch_size, bias=True)
81
+ self.norm_pix_loss = norm_pix_loss
82
+ self.initialize_weights()
83
+
84
+ def initialize_weights(self):
85
+ pos_embed = get_1d_sincos_pos_embed(self.embed_dim, self.patch_num, cls_token=True)
86
+ self.pos_embed.data.copy_(torch.from_numpy(pos_embed).float().unsqueeze(0))
87
+ decoder_pos_embed = get_1d_sincos_pos_embed(self.decoder_embed.out_features, self.patch_num, cls_token=True)
88
+ self.decoder_pos_embed.data.copy_(torch.from_numpy(decoder_pos_embed).float().unsqueeze(0))
89
+ w = self.patch_embed.proj.weight.data
90
+ torch.nn.init.xavier_uniform_(w.view([w.shape[0], -1]))
91
+ torch.nn.init.normal_(self.cls_token, std=0.02)
92
+ torch.nn.init.normal_(self.mask_token, std=0.02)
93
+ self.apply(self._init_weights)
94
+
95
+ def _init_weights(self, m):
96
+ if isinstance(m, nn.Linear):
97
+ torch.nn.init.xavier_uniform_(m.weight)
98
+ if isinstance(m, nn.Linear) and m.bias is not None:
99
+ nn.init.constant_(m.bias, 0)
100
+ elif isinstance(m, nn.LayerNorm):
101
+ nn.init.constant_(m.bias, 0)
102
+ nn.init.constant_(m.weight, 1.0)
103
+
104
+ def overlapping_patchify(self, spectra, patch_size, stride=None):
105
+ if stride is None:
106
+ stride = patch_size // 2
107
+ if spectra.dim() == 3:
108
+ if spectra.shape[1] == 1:
109
+ spectra = spectra.squeeze(1)
110
+ elif spectra.shape[2] == 1:
111
+ spectra = spectra.squeeze(2)
112
+ else:
113
+ raise ValueError(f"Expected a singleton channel dimension, got shape {tuple(spectra.shape)}")
114
+ spectra = spectra.contiguous()
115
+ batch_size, seq_len = spectra.shape
116
+ num_patches = (seq_len - patch_size) // stride + 1
117
+ patches = torch.zeros(batch_size, num_patches, patch_size, device=spectra.device, dtype=spectra.dtype)
118
+ for i in range(num_patches):
119
+ start_idx = i * stride
120
+ end_idx = start_idx + patch_size
121
+ patches[:, i, :] = spectra[:, start_idx:end_idx]
122
+ return patches
123
+
124
+ def overlapping_unpatchify(self, patches, original_length, patch_size, stride=None):
125
+ if stride is None:
126
+ stride = patch_size // 2
127
+ batch_size, num_patches, _ = patches.shape
128
+ reconstructed = torch.zeros(batch_size, original_length, device=patches.device, dtype=patches.dtype)
129
+ weight_counts = torch.zeros(batch_size, original_length, device=patches.device, dtype=patches.dtype)
130
+ patch_offsets = torch.arange(patch_size, device=patches.device)
131
+ start_positions = torch.arange(num_patches, device=patches.device) * stride
132
+ indices = start_positions.unsqueeze(1) + patch_offsets.unsqueeze(0)
133
+ valid_mask = indices < original_length
134
+ if not torch.all(valid_mask):
135
+ indices = indices[valid_mask]
136
+ patch_values = patches.reshape(batch_size, -1)[:, valid_mask.reshape(-1)]
137
+ else:
138
+ indices = indices.reshape(-1)
139
+ patch_values = patches.reshape(batch_size, -1)
140
+
141
+ index_expand = indices.unsqueeze(0).expand(batch_size, -1)
142
+ reconstructed.scatter_add_(1, index_expand, patch_values)
143
+ weight_counts.scatter_add_(1, index_expand, torch.ones_like(patch_values))
144
+ weight_counts[weight_counts == 0] = 1.0
145
+ reconstructed = reconstructed / weight_counts
146
+ return reconstructed
147
+
148
+ def random_masking(self, x, mask_ratio):
149
+ N, L, D = x.shape
150
+ len_keep = int(L * (1 - mask_ratio))
151
+ noise = torch.rand(N, L, device=x.device)
152
+ ids_shuffle = torch.argsort(noise, dim=1)
153
+ ids_restore = torch.argsort(ids_shuffle, dim=1)
154
+ ids_keep = ids_shuffle[:, :len_keep]
155
+ x_masked = torch.gather(x, dim=1, index=ids_keep.unsqueeze(-1).repeat(1, 1, D))
156
+ mask = torch.ones([N, L], device=x.device)
157
+ mask[:, :len_keep] = 0
158
+ mask = torch.gather(mask, dim=1, index=ids_restore)
159
+ return x_masked, mask, ids_restore
160
+
161
+ def forward_encoder(self, x, mask_ratio):
162
+ if x.dim() == 3 and x.shape[2] == 1:
163
+ x = x.squeeze(2)
164
+ batch_size = x.size(0)
165
+ x = self.overlapping_patchify(x, self.patch_size, stride=self.stride)
166
+ x = self.encoder_input(x)
167
+ patch_pos_embed = self.pos_embed[:, 1:, :]
168
+ x = x + patch_pos_embed[:, :x.size(1)]
169
+ x_masked, mask, ids_restore = self.random_masking(x, mask_ratio)
170
+ cls_tokens = self.cls_token.expand(batch_size, -1, -1)
171
+ x = torch.cat((cls_tokens, x_masked), dim=1)
172
+ final_seq_len = x.size(1) # 1 (CLS) + num_masked_patches
173
+ x = x + self.pos_embed[:, :final_seq_len]
174
+ for blk in self.encoder_blocks:
175
+ x = blk(x)
176
+ x = self.encoder_norm(x)
177
+ return x, mask, ids_restore
178
+
179
+ def forward_decoder(self, x, ids_restore):
180
+ batch_size = x.shape[0]
181
+ x = self.decoder_embed(x)
182
+ cls_token = x[:, :1]
183
+ x_encoded = x[:, 1:]
184
+ num_mask = ids_restore.shape[1] - x_encoded.shape[1]
185
+ if num_mask < 0:
186
+ raise ValueError("ids_restore length is smaller than encoded token length")
187
+
188
+ # Restore token order with ids_restore: [visible tokens + mask tokens] -> full sequence.
189
+ mask_tokens = self.mask_token.repeat(batch_size, num_mask, 1)
190
+ x_ = torch.cat([x_encoded, mask_tokens], dim=1)
191
+ x_ = torch.gather(
192
+ x_,
193
+ dim=1,
194
+ index=ids_restore.unsqueeze(-1).expand(-1, -1, x_.shape[-1])
195
+ )
196
+ x_complete = torch.cat([cls_token, x_], dim=1)
197
+ x_complete = x_complete + self.decoder_pos_embed[:, :x_complete.size(1)]
198
+
199
+ for blk in self.decoder_blocks:
200
+ x_complete = blk(x_complete)
201
+ x_complete = self.decoder_norm(x_complete)
202
+ x_pred = self.decoder_pred(x_complete)
203
+ x_pred = x_pred[:, 1:, :] # (128, patch_num, patch_size)
204
+ return x_pred
205
+
206
+ def forward_loss(self, spectrums, pred, mask, tgt=None):
207
+ if tgt is not None:
208
+ if tgt.dim() == 2:
209
+ tgt = tgt.unsqueeze(1)
210
+ target_spectrums = tgt
211
+ else:
212
+ target_spectrums = spectrums
213
+ target = self.overlapping_patchify(target_spectrums, self.patch_size, stride=self.stride) # shape: [N, L, patch_size*in_chans]
214
+ if self.norm_pix_loss:
215
+ mean = target.mean(dim=-1, keepdim=True)
216
+ var = target.var(dim=-1, keepdim=True)
217
+ target = (target - mean) / (var + 1.e-6)**.5
218
+ loss = (pred - target) ** 2
219
+ loss = loss.mean(dim=-1)
220
+ loss = (loss * mask).sum() / mask.sum()
221
+ return loss
222
+
223
+ def forward(self, x, mask_ratio=0.75, tgt=None):
224
+ if x.dim() == 3:
225
+ if x.shape[1] == 1:
226
+ original_length = x.shape[2]
227
+ elif x.shape[2] == 1:
228
+ original_length = x.shape[1]
229
+ else:
230
+ original_length = x.shape[-1]
231
+ elif x.dim() == 2:
232
+ original_length = x.shape[1]
233
+ else:
234
+ raise ValueError(f"Unexpected input shape {tuple(x.shape)}")
235
+ latent, mask, ids_restore = self.forward_encoder(x, mask_ratio)
236
+ cls_embed = latent[:, 0]
237
+ mean_embed = latent[:, 1:].mean(dim=1)
238
+ embedding = torch.cat([cls_embed, mean_embed], dim=1)
239
+ reconstructed_patchify = self.forward_decoder(latent, ids_restore)
240
+ loss = self.forward_loss(x, reconstructed_patchify, mask, tgt=tgt)
241
+ reconstructed = self.overlapping_unpatchify(reconstructed_patchify, original_length, self.patch_size, self.stride)
242
+ return reconstructed, embedding, mask, loss
main/Raman_Task.py ADDED
@@ -0,0 +1,992 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ # os.environ["TQDM_DISABLE_MONITOR"] = "1"
4
+ # os.environ["HSA_ENABLE_SDMA"] = "0"
5
+ # os.environ["MIOPEN_DISABLE_CACHE"] = "1"
6
+ # os.environ["PYTORCH_FLASH_SDP_ENABLED"] = "0"
7
+ # os.environ["PYTORCH_MEM_EFFICIENT_SDP_ENABLED"] = "1"
8
+ # os.environ["PYTORCH_MATH_SDP_ENABLED"] = "0"
9
+
10
+ import torch
11
+ import gc
12
+ import torch.nn as nn
13
+ import numpy as np
14
+ from .data_augumentation import augment_minority_classes, augment_all_classes_to_target
15
+ from .GEMS import MaskedAutoencoderRaman
16
+ from .evaluate_visualize import load_and_visualize_mae_model, plot_training_history, visualize_model_performance
17
+ from torch.utils.data import DataLoader
18
+ from tqdm import tqdm
19
+ tqdm.monitor_interval = 0
20
+ from .load_data import load_real_data
21
+ from numpy import unique
22
+
23
+ class RamanEncoder(nn.Module):
24
+ def __init__(self, pretrained_mae):
25
+ super().__init__()
26
+ self.encoder_input = pretrained_mae.encoder_input
27
+ self.cls_token = pretrained_mae.cls_token
28
+ self.pos_embed = pretrained_mae.pos_embed
29
+ self.encoder_blocks = pretrained_mae.encoder_blocks
30
+ self.encoder_norm = pretrained_mae.encoder_norm
31
+
32
+ self.patch_num = pretrained_mae.patch_num
33
+ self.patch_size = pretrained_mae.patch_size
34
+ self.embed_dim = pretrained_mae.embed_dim
35
+ self.stride = self.patch_size // 2
36
+
37
+ def overlapping_patchify(self, spectra, patch_size, stride=None):
38
+ if stride is None:
39
+ stride = patch_size // 2
40
+ if spectra.dim() == 3:
41
+ spectra = spectra.squeeze(1 if spectra.shape[1] == 1 else 2)
42
+ patches = spectra.unfold(dimension=1, size=patch_size, step=stride)
43
+
44
+ return patches
45
+
46
+ def forward(self, x, mask_ratio=0):
47
+ if x.dim() == 3 and x.shape[2] == 1:
48
+ x = x.squeeze(2)
49
+ batch_size = x.size(0)
50
+ x = self.overlapping_patchify(x, self.patch_size, stride=self.stride) # shape: [batch_size, num_patches, patch_size]
51
+ x = self.encoder_input(x)
52
+
53
+ patch_pos_embed = self.pos_embed[:, 1:, :]
54
+ x = x + patch_pos_embed[:, :x.size(1)]
55
+ x_masked, mask, ids_restore = self.random_masking(x, mask_ratio)
56
+ cls_tokens = self.cls_token.expand(batch_size, -1, -1)
57
+ cls_tokens = cls_tokens + self.pos_embed[:, :1, :]
58
+ x = torch.cat((cls_tokens.contiguous(), x_masked.contiguous()), dim=1)
59
+ for blk in self.encoder_blocks:
60
+ x = blk(x)
61
+ latent = self.encoder_norm(x)
62
+ cls_token = latent[:, 0]
63
+ mean_token = latent[:, 1:].mean(dim=1)
64
+ return latent, cls_token, mean_token
65
+
66
+ def random_masking(self, x, mask_ratio):
67
+ N, L, D = x.shape # batch, length, dim
68
+ len_keep = int(L * (1 - mask_ratio))
69
+ noise = torch.rand(N, L, device=x.device)
70
+ ids_shuffle = torch.argsort(noise, dim=1)
71
+ ids_restore = torch.argsort(ids_shuffle, dim=1)
72
+ ids_keep = ids_shuffle[:, :len_keep]
73
+ x_masked = torch.gather(x, dim=1, index=ids_keep.unsqueeze(-1).repeat(1, 1, D))
74
+ mask = torch.ones([N, L], device=x.device)
75
+ mask[:, :len_keep] = 0
76
+ mask = torch.gather(mask, dim=1, index=ids_restore)
77
+ return x_masked, mask, ids_restore
78
+
79
+
80
+ class RamanDecoder(nn.Module):
81
+ def __init__(self, pretrained_mae):
82
+ super().__init__()
83
+ self.decoder_embed = pretrained_mae.decoder_embed
84
+ self.mask_token = pretrained_mae.mask_token
85
+ self.decoder_pos_embed = pretrained_mae.decoder_pos_embed
86
+ self.decoder_blocks = pretrained_mae.decoder_blocks
87
+ self.decoder_norm = pretrained_mae.decoder_norm
88
+ self.decoder_pred = pretrained_mae.decoder_pred
89
+ self.patch_num = pretrained_mae.patch_num
90
+ self.patch_size = pretrained_mae.patch_size
91
+
92
+ def overlapping_unpatchify(self, patches, original_length, patch_size, stride=None):
93
+ if stride is None:
94
+ stride = patch_size
95
+ batch_size, num_patches, _ = patches.shape
96
+ reconstructed = torch.zeros(batch_size, original_length, device=patches.device, dtype=patches.dtype)
97
+ weight_counts = torch.zeros(batch_size, original_length, device=patches.device, dtype=patches.dtype)
98
+
99
+ for i in range(num_patches):
100
+ start_idx = i * stride
101
+ end_idx = start_idx + patch_size
102
+
103
+ if end_idx <= original_length:
104
+ reconstructed[:, start_idx:end_idx] += patches[:, i, :]
105
+ weight_counts[:, start_idx:end_idx] += 1.0
106
+
107
+ weight_counts[weight_counts == 0] = 1.0
108
+ reconstructed = reconstructed / weight_counts
109
+
110
+ return reconstructed
111
+
112
+ def forward(self, latent, ids_restore):
113
+
114
+ batch_size = latent.shape[0]
115
+ x = self.decoder_embed(latent)
116
+ cls_token = x[:, :1]
117
+ x_encoded = x[:, 1:]
118
+
119
+ mask_tokens = self.mask_token.repeat(batch_size, ids_restore.shape[1], 1)
120
+ x_full = torch.cat([cls_token.contiguous(), mask_tokens.contiguous()], dim=1)
121
+ valid_idx = ids_restore[:, :x_encoded.shape[1]]
122
+ if valid_idx.dtype != torch.int64:
123
+ valid_idx = valid_idx.long()
124
+ x_scatter = x_full[:, 1:].clone()
125
+ if x_scatter.dtype != x_encoded.dtype:
126
+ x_encoded = x_encoded.to(x_scatter.dtype)
127
+ if x_scatter.device != x_encoded.device:
128
+ x_encoded = x_encoded.to(x_scatter.device)
129
+ x_scatter.scatter_(
130
+ dim=1,
131
+ index=valid_idx.unsqueeze(-1).expand(-1, -1, x_encoded.shape[-1]),
132
+ src=x_encoded)
133
+ x_complete = torch.cat([cls_token.contiguous(), x_scatter.contiguous()], dim=1)
134
+ x_complete = x_complete + self.decoder_pos_embed[:, :x_complete.size(1)]
135
+ for blk in self.decoder_blocks:
136
+ x_complete = blk(x_complete)
137
+ x_complete = self.decoder_norm(x_complete)
138
+ x_pred = self.decoder_pred(x_complete)
139
+ x_pred = x_pred[:, 1:, :]
140
+ reconstructed = self.overlapping_unpatchify(x_pred, x.shape[2], self.patch_size, self.stride)
141
+ return reconstructed
142
+
143
+
144
+ class RamanClassifier(nn.Module):
145
+ def __init__(self, encoder, num_classes, dropout_rate=0.2):
146
+ super().__init__()
147
+ self.encoder = encoder
148
+ embed_dim = encoder.embed_dim
149
+
150
+ self.cls_stream = nn.Sequential(
151
+ nn.Linear(embed_dim, embed_dim // 2),
152
+ nn.BatchNorm1d(embed_dim // 2),
153
+ nn.ReLU(),
154
+ nn.Dropout(dropout_rate)
155
+ )
156
+
157
+ self.mean_stream = nn.Sequential(
158
+ nn.Linear(embed_dim, embed_dim // 2),
159
+ nn.BatchNorm1d(embed_dim // 2),
160
+ nn.ReLU(),
161
+ nn.Dropout(dropout_rate)
162
+ )
163
+
164
+ self.classifier = nn.Sequential(
165
+ nn.Linear(embed_dim, embed_dim // 2),
166
+ nn.ReLU(),
167
+ nn.Dropout(dropout_rate),
168
+ nn.Linear(embed_dim // 2, num_classes)
169
+ )
170
+
171
+ def forward(self, x):
172
+ _, cls_token, mean_token = self.encoder(x)
173
+ cls_features = self.cls_stream(cls_token)
174
+ mean_features = self.mean_stream(mean_token)
175
+ combined_features = torch.cat([cls_features.contiguous(), mean_features.contiguous()], dim=1)
176
+ logits = self.classifier(combined_features)
177
+ return logits, combined_features
178
+
179
+ def extract_embedding(self, x):
180
+ _, cls_token, mean_token = self.encoder(x)
181
+ cls_features = self.cls_stream(cls_token)
182
+ mean_features = self.mean_stream(mean_token)
183
+ return torch.cat([cls_features.contiguous(), mean_features.contiguous()], dim=1)
184
+
185
+ class RamanReconstructor(nn.Module):
186
+ def __init__(self, encoder, decoder, patch_size, stride):
187
+ super().__init__()
188
+ self.encoder = encoder
189
+ self.decoder = decoder
190
+ self.patch_size = patch_size
191
+ self.stride = stride
192
+
193
+ def forward(self, x, mask_ratio=0.5):
194
+ latent, _, ids_restore = self.encoder(x, mask_ratio=mask_ratio)
195
+ recon = self.decoder(latent, ids_restore)
196
+ return recon
197
+
198
+ def load_mae_model_for_classification(pretrained_path, input_length, patch_num, embedding_dim,
199
+ num_layers, num_heads, num_classes, device):
200
+ mae_model = MaskedAutoencoderRaman(
201
+ input_length=input_length,
202
+ patch_num=patch_num,
203
+ embed_dim=embedding_dim,
204
+ depth=num_layers,
205
+ num_heads=num_heads,
206
+ decoder_embed_dim=embedding_dim // 2,
207
+ decoder_depth=4,
208
+ decoder_num_heads=num_heads // 2
209
+ ).to(device)
210
+
211
+ try:
212
+ checkpoint = torch.load(pretrained_path, map_location=device, weights_only=False)
213
+ except TypeError:
214
+ checkpoint = torch.load(pretrained_path, map_location=device)
215
+
216
+ if 'model_state_dict' in checkpoint:
217
+ mae_model.load_state_dict(checkpoint['model_state_dict'])
218
+ print("Successfully loaded pretrained model weights")
219
+ elif 'state_dict' in checkpoint:
220
+ mae_model.load_state_dict(checkpoint['state_dict'])
221
+ print("Successfully loaded pretrained model weights")
222
+ else:
223
+ mae_model.load_state_dict(checkpoint)
224
+ print("Successfully loaded pretrained model weights")
225
+ encoder = RamanEncoder(mae_model).to(device)
226
+ decoder = RamanDecoder(mae_model).to(device)
227
+ classifier = RamanClassifier(encoder, num_classes).to(device)
228
+ return classifier, encoder, decoder, mae_model
229
+
230
+
231
+ def train_predictor(classifier, mae_model, train_loader, val_loader, test_loader,
232
+ device, epochs=100, lr=0.001, weight_decay=1e-3,
233
+ patience=15, save_dir=None,
234
+ model_name="raman", freeze_encoder=True,
235
+ label_smoothing=0.0,
236
+ progress_callback=None):
237
+
238
+ def report_progress(stage, epoch, total_epochs, message):
239
+ if progress_callback is None:
240
+ return
241
+ try:
242
+ progress_callback(stage=stage, epoch=epoch, total_epochs=total_epochs, message=message)
243
+ except Exception:
244
+ pass
245
+
246
+ gc.collect()
247
+ print(f"Starting training classification model, device: {device}")
248
+ if "cuda" in str(device) or "hip" in str(device):
249
+ torch.cuda.empty_cache()
250
+ torch.cuda.synchronize()
251
+
252
+ if save_dir and not os.path.exists(save_dir):
253
+ os.makedirs(save_dir, exist_ok=True)
254
+ if freeze_encoder:
255
+ for param in classifier.encoder.parameters():
256
+ param.requires_grad = False
257
+ total_params = sum(p.numel() for p in classifier.parameters())
258
+ trainable_params = sum(p.numel() for p in classifier.parameters() if p.requires_grad)
259
+ frozen_params = total_params - trainable_params
260
+
261
+ print(f" Total parameters: {total_params:,}")
262
+ print(f" Trainable parameters: {trainable_params:,} ({trainable_params/total_params*100:.1f}%)")
263
+ print(f" Frozen parameters: {frozen_params:,} ({frozen_params/total_params*100:.1f}%)")
264
+ else:
265
+ print(f"\n🔥 Encoder parameters will be trained together")
266
+ optimizer = torch.optim.AdamW(
267
+ filter(lambda p: p.requires_grad, classifier.parameters()),
268
+ lr=lr,
269
+ weight_decay=weight_decay,
270
+ foreach=False
271
+ )
272
+
273
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
274
+ optimizer, mode='min', factor=0.5, patience=patience // 3,
275
+ min_lr=1e-6
276
+ )
277
+
278
+ criterion = nn.CrossEntropyLoss(label_smoothing=label_smoothing)
279
+ train_losses = []
280
+ val_losses = []
281
+ train_accuracies = []
282
+ val_accuracies = []
283
+
284
+ best_val_loss = float('inf')
285
+ best_val_acc = 0.0
286
+ best_val_loss_recon = float('inf')
287
+ best_epoch = 0
288
+ no_improve_counter = 0
289
+
290
+ if save_dir:
291
+ best_class_model_path = os.path.join(save_dir, f"{model_name}_best_class.pth")
292
+ best_recon_model_path = os.path.join(save_dir, f"{model_name}_best_recon.pth")
293
+ else:
294
+ best_model_path = None
295
+
296
+ print("\nClassifier training started...")
297
+ report_progress("classification", 0, epochs, "Classifier training started")
298
+ for epoch in range(epochs):
299
+ # ===== Training phase =====
300
+ classifier.train()
301
+ train_loss = 0.0
302
+ train_correct = 0
303
+ train_total = 0
304
+ train_bar = tqdm(train_loader, desc=f"Epoch {epoch+1}")
305
+ for batch_idx, batch in enumerate(train_bar):
306
+ try:
307
+ inputs, _, labels = batch
308
+ # Uncomment the checks below if you need to debug invalid input values.
309
+ # if not torch.isfinite(input).all():
310
+ # print(f"Invalid values detected in the data.")
311
+ # print(f"Contains NaN: {torch.isnan(input).any()}")
312
+ # print(f"Contains Inf: {torch.isinf(input).any()}")
313
+ # # Print the indices of problematic samples.
314
+ # raise ValueError("Input contains NaN or Inf")
315
+
316
+ inputs = inputs.contiguous().to(device)
317
+ labels = labels.contiguous().to(device)
318
+ if labels.dim() > 1:
319
+ labels = labels.squeeze()
320
+ labels = labels.long()
321
+ optimizer.zero_grad()
322
+
323
+
324
+
325
+ logits, _ = classifier(inputs)
326
+ loss = criterion(logits, labels)
327
+ loss.backward()
328
+ # torch.nn.utils.clip_grad_value_(classifier.parameters(), clip_value=1.0)
329
+ optimizer.step()
330
+ train_loss += loss.item() * inputs.size(0)
331
+ _, predicted = torch.max(logits, 1)
332
+ train_total += labels.size(0)
333
+ train_correct += (predicted == labels).sum().item()
334
+
335
+ if batch_idx % 10 == 0:
336
+ train_bar.set_postfix({'loss': f'{loss.item():.4f}'})
337
+
338
+ except Exception as e:
339
+ print(f"\ntraining stopped: {e}")
340
+ del inputs, labels, logits
341
+ gc.collect()
342
+ torch.cuda.empty_cache()
343
+ raise e
344
+
345
+ epoch_train_loss = train_loss / len(train_loader.dataset)
346
+ epoch_train_acc = train_correct / train_total if train_total > 0 else 0
347
+
348
+ train_losses.append(epoch_train_loss)
349
+ train_accuracies.append(epoch_train_acc)
350
+
351
+ # ===== Validation phase =====
352
+ classifier.eval()
353
+ val_loss = 0.0
354
+ val_correct = 0
355
+ val_total = 0
356
+
357
+ with torch.inference_mode():
358
+ for batch in val_loader:
359
+ try:
360
+ inputs, _, labels = batch
361
+ inputs = inputs.to(device)
362
+ labels = labels.to(device)
363
+
364
+ if labels.dim() > 1:
365
+ labels = labels.squeeze()
366
+ labels = labels.long()
367
+
368
+ logits, _ = classifier(inputs)
369
+ loss = criterion(logits, labels)
370
+
371
+ val_loss += loss.item() * inputs.size(0)
372
+ _, predicted = torch.max(logits, 1)
373
+ val_total += labels.size(0)
374
+ val_correct += (predicted == labels).sum().item()
375
+ except Exception as e:
376
+ print(f"\n❌ Validation error (Epoch {epoch+1}, Batch {batch_idx+1}): {e}")
377
+ raise e
378
+
379
+ epoch_val_loss = val_loss / len(val_loader.dataset)
380
+ epoch_val_acc = val_correct / val_total if val_total > 0 else 0
381
+
382
+ val_losses.append(epoch_val_loss)
383
+ val_accuracies.append(epoch_val_acc)
384
+
385
+ scheduler.step(epoch_val_loss)
386
+ current_lr = optimizer.param_groups[0]['lr']
387
+
388
+ print(f"\nEpoch {epoch + 1}/{epochs} - "
389
+ f"Train Loss: {epoch_train_loss:.4f}, Train Acc: {epoch_train_acc:.4f}, "
390
+ f"Val Loss: {epoch_val_loss:.4f}, Val Acc: {epoch_val_acc:.4f}, "
391
+ f"LR: {current_lr:.2e}")
392
+ report_progress(
393
+ "classification",
394
+ epoch + 1,
395
+ epochs,
396
+ f"Classifier training: epoch {epoch + 1}/{epochs}",
397
+ )
398
+
399
+ if epoch_val_acc > best_val_acc or (epoch_val_acc == best_val_acc and epoch_val_loss < best_val_loss):
400
+ best_val_acc = epoch_val_acc
401
+ best_val_loss = epoch_val_loss
402
+ best_epoch = epoch + 1
403
+ no_improve_counter = 0
404
+
405
+ if best_class_model_path:
406
+ torch.save({
407
+ 'epoch': best_epoch,
408
+ 'model_state_dict': classifier.state_dict(),
409
+ 'optimizer_state_dict': optimizer.state_dict(),
410
+ 'val_loss': best_val_loss,
411
+ 'val_acc': best_val_acc,
412
+ }, best_class_model_path)
413
+ else:
414
+ no_improve_counter += 1
415
+ print(f"⏳ Validation performance did not improve ({no_improve_counter}/{patience})")
416
+
417
+ if no_improve_counter >= patience:
418
+ print(f"⏹️ Training stopped after {patience} consecutive epochs without improvement")
419
+ break
420
+
421
+ print(f"\nClassifier training completed! Best validation performance at Epoch {best_epoch}, Accuracy: {best_val_acc:.4f}")
422
+ report_progress("classification", epochs, epochs, "Classifier training completed")
423
+
424
+ if best_class_model_path and os.path.exists(best_class_model_path):
425
+ try:
426
+ checkpoint = torch.load(best_class_model_path, map_location=device, weights_only=False)
427
+ except TypeError:
428
+ checkpoint = torch.load(best_class_model_path, map_location=device)
429
+ classifier.load_state_dict(checkpoint['model_state_dict'])
430
+ print(f"✅ Best model weights loaded (Epoch {checkpoint['epoch']})")
431
+
432
+ plot_training_history(
433
+ train_losses, val_losses,
434
+ train_accuracies, val_accuracies,
435
+ save_path=os.path.join(save_dir, "training_history.png") if save_dir else None
436
+ )
437
+
438
+ print("\nReconstructor training started...")
439
+ report_progress("reconstruction", 0, epochs, "Reconstruction training started")
440
+ optimizer = torch.optim.AdamW(
441
+ mae_model.parameters(),
442
+ lr=1e-4,
443
+ weight_decay=weight_decay
444
+ )
445
+ for epoch in range(epochs):
446
+ # ===== Training phase =====
447
+ mae_model.train()
448
+ train_loss_recon = 0.0
449
+
450
+ train_bar = tqdm(train_loader, desc=f"Training Epoch {epoch + 1}/{epochs}")
451
+
452
+ for batch_idx, batch in enumerate(train_bar):
453
+ inputs, _, labels = batch
454
+ inputs = inputs.to(device)
455
+ labels = labels.to(device)
456
+ if labels.dim() > 1:
457
+ labels = labels.squeeze()
458
+ labels = labels.long()
459
+ optimizer.zero_grad()
460
+ recon, embedding, mask, loss_recon = mae_model(inputs, mask_ratio=0.5)
461
+ loss_recon.backward()
462
+ torch.nn.utils.clip_grad_norm_(mae_model.parameters(), max_norm=1.0)
463
+ optimizer.step()
464
+ train_loss_recon += loss_recon.item() * inputs.size(0)
465
+ train_bar.set_postfix({
466
+ 'loss_recon': f'{loss_recon.item():.4f}',
467
+ })
468
+
469
+ epoch_train_loss_recon = train_loss_recon / len(train_loader.dataset)
470
+
471
+ train_losses.append(epoch_train_loss_recon)
472
+
473
+ # ===== Validation phase =====
474
+ mae_model.eval()
475
+ val_loss_recon = 0.0
476
+
477
+ with torch.no_grad():
478
+ val_bar = tqdm(val_loader, desc=f"Validation Epoch {epoch + 1}/{epochs}")
479
+
480
+ for batch_idx, batch in enumerate(val_bar):
481
+ try:
482
+ inputs, _, labels = batch
483
+ inputs = inputs.to(device)
484
+ labels = labels.to(device)
485
+ if labels.dim() > 1:
486
+ labels = labels.squeeze()
487
+ labels = labels.long()
488
+ recon, embedding, mask, loss_recon = mae_model(inputs, mask_ratio=0.5)
489
+
490
+ val_loss_recon += loss_recon.item() * inputs.size(0)
491
+ val_bar.set_postfix({
492
+ 'loss_recon': f'{loss_recon.item():.4f}'
493
+ })
494
+
495
+ except Exception as e:
496
+ print(f"\n❌ Error during validation (Epoch {epoch+1}, Batch {batch_idx+1}): {e}")
497
+ raise e
498
+
499
+ epoch_val_loss_recon = val_loss_recon / len(val_loader.dataset)
500
+ val_losses.append(epoch_val_loss_recon)
501
+
502
+
503
+ scheduler.step(epoch_val_loss_recon)
504
+ current_lr = optimizer.param_groups[0]['lr']
505
+
506
+ print(f"\nEpoch {epoch + 1}/{epochs} - "
507
+ f"Train Loss: {epoch_train_loss_recon:.4f} "
508
+ f"Val Loss: {epoch_val_loss_recon:.4f} "
509
+ f"LR: {current_lr:.2e}")
510
+ report_progress(
511
+ "reconstruction",
512
+ epoch + 1,
513
+ epochs,
514
+ f"Reconstruction training: epoch {epoch + 1}/{epochs}",
515
+ )
516
+
517
+ if epoch_val_loss_recon < best_val_loss_recon:
518
+ best_val_loss_recon = epoch_val_loss_recon
519
+ best_epoch = epoch + 1
520
+ no_improve_counter = 0
521
+
522
+ if best_recon_model_path:
523
+ torch.save({
524
+ 'epoch': best_epoch,
525
+ 'model_state_dict': mae_model.state_dict(),
526
+ 'optimizer_state_dict': optimizer.state_dict(),
527
+ 'val_loss': best_val_loss_recon,
528
+ }, best_recon_model_path)
529
+ else:
530
+ no_improve_counter += 1
531
+ print(f"⏳ Validation not improved ({no_improve_counter}/{patience})")
532
+
533
+ if no_improve_counter >= patience:
534
+ print(f"⏹️ Stopping training after {patience} epochs without improvement")
535
+ break
536
+
537
+ print(f"\n Reconstructor training completed! Best validation performance at Epoch {best_epoch}, Loss: {best_val_loss_recon:.4f}")
538
+ report_progress("reconstruction", epochs, epochs, "Reconstruction training completed")
539
+
540
+ plot_training_history(
541
+ train_losses, val_losses,
542
+ train_accuracies, val_accuracies,
543
+ save_path=os.path.join(save_dir, "training_recon_history.png") if save_dir else None
544
+ )
545
+ gc.collect()
546
+ torch.cuda.empty_cache()
547
+ return classifier, mae_model
548
+
549
+ def load_class_names(mapping_path):
550
+ try:
551
+ if not os.path.exists(mapping_path):
552
+ print(f"⚠️ mapping file dose not exist: {mapping_path}")
553
+ return None
554
+
555
+ class_names = []
556
+ with open(mapping_path, 'r', encoding='utf-8') as f:
557
+ for line_num, line in enumerate(f, 1):
558
+ line = line.strip()
559
+ if not line or line.startswith('#') or line.startswith('//'):
560
+ continue
561
+ class_names.append(line)
562
+ except Exception as e:
563
+ print(f"⚠️ Failed to load class names: {e}")
564
+ return None
565
+ return class_names
566
+
567
+ def stratified_split_with_minimum_samples(X, y, test_size=0.15, val_size=0.15,
568
+ min_samples_per_class=2, random_state=42):
569
+
570
+ unique_labels_init, counts_init = np.unique(y, return_counts=True)
571
+ valid_labels = unique_labels_init[counts_init >= 3]
572
+
573
+ if len(valid_labels) < len(unique_labels_init):
574
+ print(f" ⚠️ Found {len(unique_labels_init) - len(valid_labels)} classes with fewer than 3 samples, removing them from the dataset.")
575
+ mask = np.isin(y, valid_labels)
576
+ X = X[mask]
577
+ y = y[mask]
578
+
579
+ unique_labels = np.unique(y)
580
+ num_classes = len(unique_labels)
581
+
582
+ _, label_counts = np.unique(y, return_counts=True)
583
+ if len(label_counts) > 0:
584
+ min_class_samples = label_counts.min()
585
+ else:
586
+ min_class_samples = 0
587
+
588
+ min_total_per_class = min_samples_per_class * 3
589
+
590
+ if min_class_samples < min_total_per_class:
591
+ min_samples_per_class = 1
592
+ min_total_per_class = 3
593
+
594
+ X_train_list, X_val_list, X_test_list = [], [], []
595
+ y_train_list, y_val_list, y_test_list = [], [], []
596
+
597
+ for label in unique_labels:
598
+ class_mask = (y == label)
599
+ X_class = X[class_mask]
600
+ y_class = y[class_mask]
601
+ class_size = len(X_class)
602
+
603
+ if class_size < 3:
604
+ continue
605
+
606
+ else:
607
+ test_size_class = max(min_samples_per_class, int(class_size * test_size))
608
+ val_size_class = max(min_samples_per_class, int(class_size * val_size))
609
+ train_size_class = class_size - test_size_class - val_size_class
610
+
611
+ if train_size_class < min_samples_per_class:
612
+ train_size_class = min_samples_per_class
613
+ remaining = class_size - train_size_class
614
+ test_size_class = remaining // 2
615
+ val_size_class = remaining - test_size_class
616
+
617
+ np.random.seed(random_state + label)
618
+ indices = np.random.permutation(class_size)
619
+
620
+ test_indices = indices[:test_size_class]
621
+ val_indices = indices[test_size_class:test_size_class + val_size_class]
622
+ train_indices = indices[test_size_class + val_size_class:]
623
+
624
+ print(f" train{len(train_indices)}, val{len(val_indices)}, test{len(test_indices)}")
625
+
626
+ if len(train_indices) > 0:
627
+ X_train_list.append(X_class[train_indices])
628
+ y_train_list.append(y_class[train_indices])
629
+
630
+ if len(val_indices) > 0:
631
+ X_val_list.append(X_class[val_indices])
632
+ y_val_list.append(y_class[val_indices])
633
+
634
+ if len(test_indices) > 0:
635
+ X_test_list.append(X_class[test_indices])
636
+ y_test_list.append(y_class[test_indices])
637
+
638
+ X_train = np.vstack(X_train_list) if X_train_list else np.empty((0, X.shape[1]))
639
+ X_val = np.vstack(X_val_list) if X_val_list else np.empty((0, X.shape[1]))
640
+ X_test = np.vstack(X_test_list) if X_test_list else np.empty((0, X.shape[1]))
641
+
642
+ y_train = np.concatenate(y_train_list) if y_train_list else np.empty(0, dtype=y.dtype)
643
+ y_val = np.concatenate(y_val_list) if y_val_list else np.empty(0, dtype=y.dtype)
644
+ y_test = np.concatenate(y_test_list) if y_test_list else np.empty(0, dtype=y.dtype)
645
+
646
+ for split_name, (X_split, y_split) in [("train", (X_train, y_train)),
647
+ ("val", (X_val, y_val)),
648
+ ("test", (X_test, y_test))]:
649
+ if len(X_split) > 0:
650
+ split_seeds = {"train": 123, "val": 456, "test": 789}
651
+ safe_seed = (random_state + split_seeds[split_name]) % (2**32 - 1)
652
+ np.random.seed(safe_seed)
653
+ shuffle_indices = np.random.permutation(len(X_split))
654
+
655
+ if split_name == "train":
656
+ X_train, y_train = X_train[shuffle_indices], y_train[shuffle_indices]
657
+ elif split_name == "val":
658
+ X_val, y_val = X_val[shuffle_indices], y_val[shuffle_indices]
659
+ else:
660
+ X_test, y_test = X_test[shuffle_indices], y_test[shuffle_indices]
661
+
662
+ return X_train, X_val, X_test, y_train, y_val, y_test
663
+
664
+
665
+ def validate_split_completeness(y_train, y_val, y_test, num_classes):
666
+
667
+ all_y = np.concatenate([y_train, y_val, y_test]) if len(y_train) or len(y_val) or len(y_test) else np.array([], dtype=int)
668
+ unique_labels, counts = np.unique(all_y, return_counts=True)
669
+ class_counts = dict(zip(unique_labels, counts))
670
+
671
+ valid_classes = sorted([c for c, cnt in class_counts.items() if cnt >= 3])
672
+ dropped_classes = sorted([c for c, cnt in class_counts.items() if cnt < 3])
673
+
674
+ if not valid_classes:
675
+ print(" cannot validate split completeness: no classes with at least 3 samples found.")
676
+ return False
677
+
678
+ print(f" valid classes (samples≥3): {valid_classes}")
679
+ if dropped_classes:
680
+ print(f" dropped classes (samples<3): {dropped_classes}")
681
+
682
+ valid_set = set(valid_classes)
683
+ train_classes = set(np.unique(y_train)) & valid_set
684
+ val_classes = set(np.unique(y_val)) & valid_set
685
+ test_classes = set(np.unique(y_test)) & valid_set
686
+
687
+ # print(f" train classes: {sorted(train_classes)}")
688
+ # print(f" val classes: {sorted(val_classes)}")
689
+ # print(f" test classes: {sorted(test_classes)}")
690
+
691
+ train_missing = valid_set - train_classes
692
+ val_missing = valid_set - val_classes
693
+ test_missing = valid_set - test_classes
694
+
695
+ success = True
696
+ if train_missing:
697
+ print(f" ❌ train missing classes: {sorted(train_missing)}")
698
+ success = False
699
+ if val_missing:
700
+ print(f" ❌ val missing classes: {sorted(val_missing)}")
701
+ success = False
702
+ if test_missing:
703
+ print(f" ❌ test missing classes: {sorted(test_missing)}")
704
+ success = False
705
+
706
+ if success:
707
+ print(f" ✅ all splits contain all classes involved in validation!")
708
+
709
+ for split_name, y_split in [("train", y_train), ("val", y_val), ("test", y_test)]:
710
+ u, c = np.unique(y_split, return_counts=True)
711
+ dist = {lbl: cnt for lbl, cnt in zip(u, c) if lbl in valid_set}
712
+ print(f" {split_name} set ({len(y_split)} samples):")
713
+ for lbl in valid_classes:
714
+ count = dist.get(lbl, 0)
715
+ status = "✅" if count > 0 else "❌"
716
+ return success
717
+
718
+ def main():
719
+ from Ramandataset import RamanDataset
720
+ base_path = '/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/'
721
+
722
+ # skin cancer
723
+ # data_path = os.path.join(base_path, 'skin_cancer/skin_cancer_spectra.npy')
724
+ # labels_path = os.path.join(base_path, 'skin_cancer/skin_cancer_labels.npy')
725
+ # wavenumbers_path = os.path.join(base_path, 'skin_cancer/skin_cancer_wavenumber.npy')
726
+
727
+ #pigment
728
+ data_path = os.path.join(base_path, 'rruff/classifier_0_3500_spectra.npy')
729
+ labels_path = os.path.join(base_path, 'rruff/classifier_0_3500_labels.npy')
730
+ wavenumbers_path = os.path.join(base_path, 'rruff/classifier_0_3500_wavenumbers.npy')
731
+
732
+ # bacteria id
733
+ # data_path = os.path.join(base_path, 'bacteria-ID datasets/X_reference_interpolated.npy')
734
+ # labels_path = os.path.join(base_path, 'bacteria-ID datasets/y_reference.npy')
735
+ # wavenumbers_path = os.path.join(base_path, 'bacteria-ID datasets/wavenumbers_interpolated_3500.npy')
736
+
737
+ # #microplastic
738
+ # data_path = os.path.join(base_path, 'processed_raman_data/microplastic_0_3500_spectra.npy')
739
+ # labels_path = os.path.join(base_path, 'processed_raman_data/microplastic_0_3500_labels.npy')
740
+ # wavenumbers_path = os.path.join(base_path, 'processed_raman_data/common_wavelengths_3500pts_0_3500.npy')
741
+
742
+ # obvious study
743
+ # data_path = os.path.join(base_path, 'rruff/pretrain_RRUFF_0_3500_spectra.npy')
744
+ # labels_path = os.path.join(base_path, 'rruff/pretrain_RRUFF_0_3500_labels.npy')
745
+ # wavenumbers_path = os.path.join(base_path, 'rruff/pretrain_RRUFF_0_3500_wavenumbers.npy')
746
+
747
+ spectra, labels, wavenumbers = load_real_data(data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True)
748
+
749
+ print(f'wavenumbers shape:{wavenumbers.shape}, range: {wavenumbers[0]}-{wavenumbers[-1]} cm^-1')
750
+ input_length = spectra.shape[1]
751
+
752
+ num_classes = len(unique(labels))
753
+ print(f"🔬 Loaded data: {spectra.shape[0]} spectra, each of length {input_length}, number of classes: {num_classes}"
754
+ )
755
+ class_names = [str(label) for label in unique(labels)]
756
+ n_samples = spectra.shape[0]
757
+
758
+ mapping_path = os.path.join(base_path, 'rruff/classifier_0_3500_label_mapping.json')
759
+
760
+ real_class_names = load_class_names(mapping_path)
761
+ print(real_class_names)
762
+ if real_class_names:
763
+ class_names = real_class_names
764
+ print(f"✅ Loaded real class names: {class_names[:5]}...") # Show first 5
765
+ else:
766
+ class_names = [f"SkinCancer_{i}" for i in range(num_classes)]
767
+ print(f"⚠️ Using default class names")
768
+
769
+
770
+ # unique_labels, counts = np.unique(labels, return_counts=True)
771
+ # print(f"\n📈 Original class distribution:")
772
+ # for label, count in zip(unique_labels, counts):
773
+ # print(f" Class {label}: {count:<3d} samples")
774
+ # class_names = unique_labels
775
+ # min_samples = counts.min()
776
+ # print(f"\n⚠️ Minimum samples in any class: {min_samples}")
777
+
778
+ from sklearn.preprocessing import LabelEncoder
779
+ y = np.array(labels)
780
+ le = LabelEncoder()
781
+ y_encoded = le.fit_transform(y)
782
+ X_train, X_val, X_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
783
+ spectra, y_encoded,
784
+ test_size=0.1,
785
+ val_size=0.1,
786
+ min_samples_per_class=1,
787
+ random_state=42
788
+ )
789
+
790
+ is_valid = validate_split_completeness(y_train, y_val, y_test, num_classes)
791
+
792
+ if not is_valid:
793
+ print(f"\n❌ Split validation failed, trying to adjust parameters...")
794
+
795
+ X_train, X_val, X_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
796
+ spectra, labels,
797
+ test_size=0.1,
798
+ val_size=0.1,
799
+ min_samples_per_class=1,
800
+ random_state=42
801
+ )
802
+ is_valid = validate_split_completeness(y_train, y_val, y_test, num_classes)
803
+
804
+ if not is_valid:
805
+ print(f"\n❌ cannot create valid splits that include all classes, aborting.")
806
+ return
807
+
808
+ print(f"\n✅ Data split successful!")
809
+ print(f" Training set: {len(X_train)} samples ({len(X_train)/len(spectra)*100:.1f}%)")
810
+ print(f" Validation set: {len(X_val)} samples ({len(X_val)/len(spectra)*100:.1f}%)")
811
+ print(f" Test set: {len(X_test)} samples ({len(X_test)/len(spectra)*100:.1f}%)")
812
+
813
+
814
+ # data augmentation for minority classes
815
+ X_train_augmented, y_train_augmented = augment_minority_classes(
816
+ X_train, y_train, min_samples=30, target_samples=159
817
+ )
818
+ if len(np.unique(y_train_augmented)) == 1:
819
+ print(f"❌ data augmentation might went wrong{y_train_augmented[0]}")
820
+ X_train_augmented, y_train_augmented = X_train.copy(), y_train.copy()
821
+ print(f"\n📊 After augmentation, training set size: {len(X_train_augmented)} samples")
822
+
823
+ batch_size = 64
824
+ train_transform = None
825
+ data_alter = None
826
+ X_train_augmented = X_train
827
+ y_train_augmented = y_train
828
+ train_dataset = RamanDataset(X_train_augmented, data_alter, labels=y_train_augmented, transform=train_transform, is_train=True)
829
+ train_loader = DataLoader(
830
+ train_dataset,
831
+ batch_size=batch_size,
832
+ shuffle=True,
833
+ drop_last=True,
834
+ num_workers=0,
835
+ pin_memory=False
836
+ )
837
+ test_batch = next(iter(train_loader))
838
+ test_inputs, _, test_labels = test_batch
839
+ val_dataset = RamanDataset(X_val, data_alter, labels=y_val, transform=None, is_train=True)
840
+ val_loader = DataLoader(
841
+ val_dataset,
842
+ batch_size=batch_size,
843
+ shuffle=False,
844
+ num_workers=0,
845
+ pin_memory=False
846
+ )
847
+ test_dataset = RamanDataset(X_test, data_alter, labels=y_test, transform=None, is_train=True)
848
+ test_loader = DataLoader(
849
+ test_dataset,
850
+ batch_size=batch_size,
851
+ shuffle=False,
852
+ num_workers=0,
853
+ pin_memory=False
854
+ )
855
+
856
+ para = {"input_length": spectra.shape[1],
857
+ "embedding_dim": 512,
858
+ "num_heads": 16,
859
+ "num_layers": 12,
860
+ "patch_num": 100,
861
+ "epoch": 300,
862
+ "patch_size": spectra.shape[1] // 100,
863
+ "lr_list": [1e-4],
864
+ "mask_ratio": 0.5,
865
+ "model": 'MAE',
866
+ "contrastive_weight": 0.75}
867
+
868
+ input_length = spectra.shape[1]
869
+ embedding_dim = para["embedding_dim"]
870
+ num_heads = para["num_heads"]
871
+ num_layers = para["num_layers"]
872
+ patch_num = para["patch_num"]
873
+ mask_ratio_value = para['mask_ratio']
874
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
875
+ print(f"\n💻 Using device: {device}")
876
+ pretrained_dir = f'/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/{para["lr_list"][0]}_{mask_ratio_value}_{embedding_dim}_{num_heads}_{num_layers}_{patch_num}/'
877
+ pretrained_path = os.path.join(pretrained_dir, f"optimization_study_contrastive_weight/{para["contrastive_weight"]}/Fine_tuned.pth")
878
+ save_dir = os.path.join(pretrained_dir, f"optimization_study_contrastive_weight/{para["contrastive_weight"]}/results")
879
+ os.makedirs(save_dir, exist_ok=True)
880
+
881
+ with open(os.path.join(save_dir, "config.txt"), 'w') as f:
882
+ f.write(f"data path: {data_path}\n")
883
+ f.write(f"input length: {para['input_length']}\n")
884
+ f.write(f"number of patches: {para['patch_num']}\n")
885
+ f.write(f"embedding dimension: {para['embedding_dim']}\n")
886
+ f.write(f"number of layers: {para['num_layers']}\n")
887
+ f.write(f"number of attention heads: {para['num_heads']}\n")
888
+ f.write(f"number of classes: {num_classes}\n")
889
+ f.write(f"batch size: {batch_size}\n")
890
+ f.write(f"number of epochs: {para['epoch']}\n")
891
+ f.write(f"learning rate: {para['lr_list'][0]}\n")
892
+
893
+ print(f"config is saved: {os.path.join(save_dir, 'config.txt')}")
894
+
895
+ classifier, encoder, decoder, mae_model = load_mae_model_for_classification(
896
+ pretrained_path, para['input_length'], para['patch_num'], para['embedding_dim'],
897
+ para['num_layers'], para['num_heads'], num_classes, device
898
+ )
899
+
900
+
901
+ # train and evaluate the classifier
902
+ trained_model, mae_model = train_predictor(
903
+ classifier=classifier,
904
+ mae_model=mae_model,
905
+ train_loader=train_loader,
906
+ val_loader=val_loader,
907
+ test_loader=test_loader,
908
+ device=device,
909
+ epochs=para['epoch'],
910
+ lr=1e-4,
911
+ weight_decay=1e-3,
912
+ patience=20,
913
+ save_dir=save_dir,
914
+ model_name="raman",
915
+ freeze_encoder=False
916
+ )
917
+
918
+ final_model_path = os.path.join(save_dir, "final_model.pth")
919
+ torch.save({
920
+ 'model_state_dict': trained_model.state_dict(),
921
+ 'model_config': {
922
+ 'input_length': para['input_length'],
923
+ 'patch_num': para['patch_num'],
924
+ 'embedding_dim': para['embedding_dim'],
925
+ 'num_layers': para['num_layers'],
926
+ 'num_heads': para['num_heads'],
927
+ 'num_classes': num_classes
928
+ }
929
+ }, final_model_path)
930
+
931
+ final_mae_model_path = os.path.join(save_dir, "final_mae_model.pth")
932
+ torch.save({
933
+ 'encoder_state_dict': encoder.state_dict(),
934
+ 'decoder_state_dict': decoder.state_dict(),
935
+ 'model_config': {
936
+ 'input_length': para['input_length'],
937
+ 'patch_num': para['patch_num'],
938
+ 'embedding_dim': para['embedding_dim'],
939
+ 'num_layers': para['num_layers'],
940
+ 'num_heads': para['num_heads']
941
+ }
942
+ }, final_mae_model_path)
943
+
944
+
945
+ model_dir = save_dir
946
+ # best_recon_model_path = pretrained_path
947
+ # best_recon_model_path = os.path.join(model_dir, "raman_best_recon.pth")
948
+
949
+ # if best_recon_model_path and os.path.exists(best_recon_model_path):
950
+ # checkpoint = torch.load(best_recon_model_path, map_location=device)
951
+ # mae_model.load_state_dict(checkpoint['model_state_dict'])
952
+ # print(f"✅ successfully loaded (Epoch {checkpoint['epoch']})")
953
+ best_class_model_path = os.path.join(model_dir, "raman_best_class.pth")
954
+ if best_class_model_path and os.path.exists(best_class_model_path):
955
+ try:
956
+ checkpoint = torch.load(best_class_model_path, map_location=device, weights_only=False)
957
+ except TypeError:
958
+ checkpoint = torch.load(best_class_model_path, map_location=device)
959
+ classifier.load_state_dict(checkpoint['model_state_dict'])
960
+ print(f"✅ successfully loaded (Epoch {checkpoint['epoch']})")
961
+ print("\n📊 Evaluating model performance...")
962
+ results = visualize_model_performance(
963
+ classifier,
964
+ test_loader,
965
+ device,
966
+ class_names=class_names,
967
+ save_dir=save_dir
968
+ )
969
+
970
+ # explanation_results = analyze_model(classifier, data_path, labels_path,
971
+ # wavenumbers_path, save_dir=save_dir)
972
+
973
+ # print("\n📊 Evaluating Reconstructor model performance...")
974
+ # status = 'downtask'
975
+ # load_and_visualize_mae_model(
976
+ # best_recon_model_path,
977
+ # status,
978
+ # test_dataset,
979
+ # device,
980
+ # save_dir,
981
+ # input_length,
982
+ # patch_num=para['patch_num'],
983
+ # wavenumbers=wavenumbers,
984
+ # embedding_dim=para['embedding_dim'],
985
+ # num_heads=para['num_heads'],
986
+ # num_layers=para['num_layers']
987
+ # )
988
+
989
+ print("Model evaluation and visualization completed!")
990
+
991
+ if __name__ == "__main__":
992
+ main()
main/Ramandataset.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch.utils.data import Dataset
2
+ import torch
3
+ import numpy as np
4
+ from torchvision import transforms
5
+ import matplotlib.pyplot as plt
6
+
7
+ def get_transforms():
8
+ class AddNoise(object):
9
+ def __init__(self, noise_level_range=(0.01, 0.05)):
10
+ """Add realistic instrument noise (1-5% of signal).
11
+
12
+ Simulates both read-out noise and photon noise typical of:
13
+ - High-end spectrometer: SNR ~100:1 (1% noise)
14
+ - Standard Raman: SNR ~50:1 (2% noise)
15
+ - Portable device: SNR ~20:1 (5% noise)
16
+
17
+ Args:
18
+ noise_level_range: tuple of (min, max) noise level as fraction of signal
19
+ """
20
+ self.noise_level_range = noise_level_range
21
+
22
+ def __call__(self, tensor):
23
+ noise_level = np.random.uniform(self.noise_level_range[0], self.noise_level_range[1])
24
+ # Signal-dependent noise component (approximates photon noise)
25
+ signal_dependent_noise_level = noise_level * (1.0 + tensor * 0.1)
26
+ noise = torch.randn_like(tensor) * signal_dependent_noise_level
27
+ result = tensor + noise
28
+ result = torch.clamp(result, min=0.0, max=1.1)
29
+ return result
30
+
31
+ class AddFluorescenceBackground(object):
32
+ def __init__(self, intensity_range=(0.02, 0.08)):
33
+ """Add realistic fluorescence background (2-8% of signal).
34
+
35
+ Args:
36
+ intensity_range: range for fluorescence intensity as fraction of signal
37
+ """
38
+ self.intensity_range = intensity_range
39
+ self.background_types = ['exponential', 'polynomial', 'gaussian_broad']
40
+
41
+ def __call__(self, tensor):
42
+ if tensor.dim() == 2:
43
+ tensor = tensor.unsqueeze(1)
44
+
45
+ batch_size, channels, length = tensor.shape
46
+ x = torch.linspace(0, 1, length).to(tensor.device)
47
+ max_intensity = np.random.uniform(self.intensity_range[0], self.intensity_range[1])
48
+ bg_type = np.random.choice(self.background_types)
49
+
50
+ if bg_type == 'exponential':
51
+ # Exponential decay background (typical for fluorescence)
52
+ decay_rate = torch.rand(batch_size, 1, 1, device=tensor.device) * 3 + 1
53
+ intensity = torch.rand(batch_size, 1, 1, device=tensor.device) * max_intensity
54
+ background = intensity * torch.exp(-decay_rate * x)
55
+
56
+ elif bg_type == 'polynomial':
57
+ # Low-order polynomial background
58
+ a = torch.rand(batch_size, 1, 1, device=tensor.device) * 0.02 - 0.01
59
+ b = torch.rand(batch_size, 1, 1, device=tensor.device) * max_intensity
60
+ background = a * x * x + b * x
61
+ background = torch.clamp(background, min=0.0)
62
+
63
+ else: # gaussian_broad
64
+ # Broad Gaussian background
65
+ center = torch.rand(batch_size, 1, 1, device=tensor.device) * 0.6 + 0.2
66
+ width = torch.rand(batch_size, 1, 1, device=tensor.device) * 0.4 + 0.3
67
+ intensity = torch.rand(batch_size, 1, 1, device=tensor.device) * max_intensity
68
+ background = intensity * torch.exp(-((x - center) / width) ** 2)
69
+
70
+ background = background.expand(batch_size, channels, length)
71
+
72
+ # Suppress background in high-intensity peak regions (natural behavior)
73
+ peak_threshold = 0.1
74
+ peak_mask = tensor > peak_threshold
75
+ background_weight = torch.where(peak_mask,
76
+ torch.tensor(0.1, device=tensor.device),
77
+ torch.tensor(1.0, device=tensor.device))
78
+
79
+ adjusted_background = background * background_weight
80
+ result = tensor + adjusted_background
81
+ result = torch.clamp(result, min=0.0, max=1.2)
82
+ return result
83
+
84
+ class AddBaseline(object):
85
+ def __init__(self, coeff_range=(0.001, 0.01)):
86
+ """Add realistic baseline drift (0.1%-1% of signal).
87
+
88
+ Args:
89
+ coeff_range: range for polynomial coefficients
90
+ """
91
+ self.coeff_range = coeff_range
92
+
93
+ def __call__(self, tensor):
94
+ if tensor.dim() == 2:
95
+ tensor = tensor.unsqueeze(1)
96
+
97
+ batch_size, channels, length = tensor.shape
98
+ x = torch.linspace(0, 1, length).to(tensor.device)
99
+ max_coeff = np.random.uniform(self.coeff_range[0], self.coeff_range[1])
100
+
101
+ # Use 2nd order polynomial for baseline (sufficient for typical drift)
102
+ # Coefficients randomly sampled from [-max_coeff, max_coeff]
103
+ a = (torch.rand(batch_size, 1, 1, device=tensor.device) - 0.5) * 2 * max_coeff
104
+ b = (torch.rand(batch_size, 1, 1, device=tensor.device) - 0.5) * 2 * max_coeff
105
+ c = torch.rand(batch_size, 1, 1, device=tensor.device) * max_coeff
106
+
107
+ # Build 2nd order polynomial baseline: a*x^2 + b*x + c
108
+ baseline = a * x * x + b * x + c
109
+ # No arbitrary scaling - let parameters control magnitude
110
+ baseline = baseline.expand(batch_size, channels, length)
111
+
112
+ result = tensor + baseline
113
+ result = torch.clamp(result, min=0.0, max=1.1)
114
+ return result
115
+
116
+ class AddCosmicRays(object):
117
+ def __init__(self, spike_probability=0.0001, max_spike_intensity=5.0):
118
+ """Add rare cosmic ray spikes (realistic ~0.01% probability).
119
+
120
+ Args:
121
+ spike_probability: probability of spike per pixel
122
+ max_spike_intensity: max intensity of spike relative to signal
123
+ """
124
+ self.spike_probability = spike_probability
125
+ self.max_spike_intensity = max_spike_intensity
126
+
127
+ def __call__(self, tensor):
128
+ # Only add cosmic rays 30% of the time (rare event)
129
+ if torch.rand(1).item() > 0.3:
130
+ return tensor
131
+
132
+ if tensor.dim() == 2:
133
+ tensor = tensor.unsqueeze(1)
134
+
135
+ batch_size, channels, length = tensor.shape
136
+ num_spikes = np.random.poisson(length * self.spike_probability)
137
+ if num_spikes > 0:
138
+ spike_positions = torch.randint(0, length, (num_spikes,))
139
+ spike_intensities = torch.rand(num_spikes) * self.max_spike_intensity
140
+
141
+ for pos, intensity in zip(spike_positions, spike_intensities):
142
+ spike_width = np.random.randint(1, 4)
143
+ start_pos = max(0, pos - spike_width // 2)
144
+ end_pos = min(length, pos + spike_width // 2 + 1)
145
+ spike_x = torch.arange(start_pos, end_pos, dtype=torch.float32)
146
+ spike_profile = intensity * torch.exp(-((spike_x - pos) ** 2) / (spike_width / 3) ** 2)
147
+
148
+ tensor[:, :, start_pos:end_pos] += spike_profile.unsqueeze(0).unsqueeze(0)
149
+
150
+ return tensor
151
+
152
+ class AddIntensityFluctuation(object):
153
+ def __init__(self, max_fluctuation=0.005):
154
+ """Add laser power fluctuations (0.5-1% typical).
155
+
156
+ Args:
157
+ max_fluctuation: maximum fluctuation amplitude (0.005 = 0.5%)
158
+ """
159
+ self.max_fluctuation = max_fluctuation
160
+
161
+ def __call__(self, tensor):
162
+ if tensor.dim() == 2:
163
+ tensor = tensor.unsqueeze(1)
164
+
165
+ batch_size, channels, length = tensor.shape
166
+ # Global laser power drift
167
+ global_factor = 1 + (torch.rand(batch_size, 1, 1, device=tensor.device) - 0.5) * self.max_fluctuation * 2
168
+
169
+ # Local low-frequency intensity modulation
170
+ x = torch.linspace(0, 1, length).to(tensor.device)
171
+ local_freq = torch.rand(batch_size, 1, 1, device=tensor.device) * 2 + 0.5 # 0.5-2.5 Hz
172
+ local_amplitude = torch.rand(batch_size, 1, 1, device=tensor.device) * self.max_fluctuation
173
+ local_phase = torch.rand(batch_size, 1, 1, device=tensor.device) * 2 * np.pi
174
+
175
+ local_fluctuation = 1 + local_amplitude * torch.sin(2 * np.pi * local_freq * x + local_phase)
176
+ local_fluctuation = local_fluctuation.expand(batch_size, channels, length)
177
+
178
+ return tensor * global_factor * local_fluctuation
179
+
180
+ class ShiftSpectrum(object):
181
+ def __init__(self, max_shift=5):
182
+ self.max_shift = max_shift
183
+
184
+ def __call__(self, tensor):
185
+ shift = np.random.randint(-self.max_shift, self.max_shift + 1)
186
+ if shift == 0:
187
+ return tensor
188
+
189
+ if tensor.dim() == 2:
190
+ tensor = tensor.unsqueeze(1)
191
+
192
+ result = torch.zeros_like(tensor)
193
+ length = tensor.shape[2]
194
+
195
+ if shift > 0:
196
+ result[:, :, shift:] = tensor[:, :, :-shift]
197
+ result[:, :, :shift] = tensor[:, :, :1].expand(-1, -1, shift)
198
+ else:
199
+ result[:, :, :length + shift] = tensor[:, :, -shift:]
200
+ result[:, :, length + shift:] = tensor[:, :, -1:].expand(-1, -1, -shift)
201
+
202
+ return result
203
+
204
+ train_transform = transforms.Compose([
205
+ # Realistic instrument noise levels (based on typical Raman spectrometers)
206
+ # ReadOut noise + photon noise: 1-5% of signal level
207
+ AddNoise(noise_level_range=(0.01, 0.05)),
208
+ # Fluorescence background: 2-8% of signal intensity
209
+ AddFluorescenceBackground(intensity_range=(0.02, 0.08)),
210
+ # Baseline drift from temperature/laser fluctuation: 0.1-1%
211
+ AddBaseline(coeff_range=(0.001, 0.01)),
212
+ # Cosmic ray spikes (rare event): ~0.01% probability per pixel
213
+ AddCosmicRays(spike_probability=0.0001),
214
+ # Laser power fluctuation: 0.5-1% intensity variation
215
+ AddIntensityFluctuation(max_fluctuation=0.005),
216
+ # Small wavenumber shifts from calibration drift: ±3-5 pixels
217
+ ShiftSpectrum(max_shift=3)
218
+ ])
219
+ count = 0
220
+ if count == 1:
221
+ try:
222
+ length = 3500
223
+ x = np.linspace(0, 1, length)
224
+ signal = np.zeros(length)
225
+ peaks = [
226
+ (0.1, 0.8, 0.02),
227
+ (0.25, 1.2, 0.015),
228
+ (0.4, 0.6, 0.025),
229
+ (0.6, 1.0, 0.02),
230
+ (0.8, 0.7, 0.018)
231
+ ]
232
+
233
+ for pos, intensity, width in peaks:
234
+ signal += intensity * np.exp(-((x - pos) / width) ** 2)
235
+ signal += 0.1 + 0.05 * x
236
+ original_tensor = torch.FloatTensor(signal).unsqueeze(0)
237
+ augmented_tensor = train_transform(original_tensor.clone())
238
+ original_spectrum = original_tensor.squeeze().numpy()
239
+ augmented_spectrum = augmented_tensor.squeeze().numpy()
240
+ plt.figure(figsize=(16, 10))
241
+
242
+ plt.subplot(2, 3, 1)
243
+ plt.plot(x, original_spectrum, 'b-', linewidth=1.5, label='original_spectrum')
244
+ plt.title('original raman spectra', fontsize=12, fontweight='bold')
245
+ plt.xlabel('normalized wavenumber')
246
+ plt.ylabel('intensity (a.u.)')
247
+ plt.grid(True, alpha=0.3)
248
+ plt.legend()
249
+
250
+ # 2. augmented spectrum
251
+ plt.subplot(2, 3, 2)
252
+ plt.plot(x, augmented_spectrum, 'r-', linewidth=1.5, label='augmented_spectrum')
253
+ plt.title('augmented spectrum', fontsize=12, fontweight='bold')
254
+ plt.xlabel('normalized wavenumber')
255
+ plt.ylabel('intensity (a.u.)')
256
+ plt.grid(True, alpha=0.3)
257
+ plt.legend()
258
+
259
+ # 3. comparison
260
+ plt.subplot(2, 3, 3)
261
+ plt.plot(x, original_spectrum, 'b-', linewidth=1.5, alpha=0.7, label='original')
262
+ plt.plot(x, augmented_spectrum, 'r-', linewidth=1.5, alpha=0.7, label='augmented')
263
+ plt.title('comparison', fontsize=12, fontweight='bold')
264
+ plt.xlabel('normalized wavenumber')
265
+ plt.ylabel('intensity (a.u.)')
266
+ plt.grid(True, alpha=0.3)
267
+ plt.legend()
268
+
269
+ # 4. difference plot
270
+ plt.subplot(2, 3, 4)
271
+ difference = augmented_spectrum - original_spectrum
272
+ plt.plot(x, difference, 'g-', linewidth=1.5, label='difference (augmented - original)')
273
+ plt.title('difference plot', fontsize=12, fontweight='bold')
274
+ plt.xlabel('normalized wavenumber')
275
+ plt.ylabel('intensity difference')
276
+ plt.grid(True, alpha=0.3)
277
+ plt.legend()
278
+ plt.axhline(y=0, color='k', linestyle='--', alpha=0.5)
279
+
280
+ # 5. local zoom (first half)
281
+ plt.subplot(2, 3, 5)
282
+ mid_point = length // 2
283
+ plt.plot(x[:mid_point], original_spectrum[:mid_point], 'b-', linewidth=1.5, alpha=0.7, label='original')
284
+ plt.plot(x[:mid_point], augmented_spectrum[:mid_point], 'r-', linewidth=1.5, alpha=0.7, label='augmented')
285
+ plt.title('local zoom (first half)', fontsize=12, fontweight='bold')
286
+ plt.xlabel('normalized wavenumber')
287
+ plt.ylabel('intensity (a.u.)')
288
+ plt.grid(True, alpha=0.3)
289
+ plt.legend()
290
+
291
+ # 6. statistical information
292
+ plt.subplot(2, 3, 6)
293
+ plt.axis('off')
294
+
295
+ # Calculate statistical information
296
+ original_stats = {
297
+ 'Max': np.max(original_spectrum),
298
+ 'Min': np.min(original_spectrum),
299
+ 'Mean': np.mean(original_spectrum),
300
+ 'Std': np.std(original_spectrum),
301
+ 'SNR': 10 * np.log10(np.mean(original_spectrum**2) / np.mean((original_spectrum - np.mean(original_spectrum))**2))
302
+ }
303
+
304
+ augmented_stats = {
305
+ 'Max': np.max(augmented_spectrum),
306
+ 'Min': np.min(augmented_spectrum),
307
+ 'Mean': np.mean(augmented_spectrum),
308
+ 'Std': np.std(augmented_spectrum),
309
+ 'SNR': 10 * np.log10(np.mean(augmented_spectrum**2) / np.mean((augmented_spectrum - np.mean(augmented_spectrum))**2))
310
+ }
311
+
312
+ stats_text = "📊 Statistical Comparison:\n\n"
313
+ stats_text += f"{'Parameter':<8} {'Original':<10} {'Augmented':<10}\n"
314
+ stats_text += "-" * 30 + "\n"
315
+ for key in original_stats:
316
+ stats_text += f"{key:<8} {original_stats[key]:<10.3f} {augmented_stats[key]:<10.3f}\n"
317
+
318
+ plt.text(0.1, 0.9, stats_text, transform=plt.gca().transAxes,
319
+ verticalalignment='top', fontfamily='monospace', fontsize=10,
320
+ bbox=dict(boxstyle='round', facecolor='lightgray', alpha=0.8))
321
+
322
+ plt.suptitle('🎨 Raman Spectra Data Augmentation Effect Demonstration', fontsize=16, fontweight='bold')
323
+ plt.tight_layout()
324
+
325
+ # Save image
326
+ import os
327
+ os.makedirs('output', exist_ok=True)
328
+ save_path = 'output/data_augmentation_demo.png'
329
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
330
+ print(f"✅ Data augmentation demonstration image saved to: {save_path}")
331
+
332
+ except Exception as e:
333
+ print(f"❌ Visualization generation failed: {e}")
334
+ import traceback
335
+ traceback.print_exc()
336
+
337
+ return train_transform
338
+
339
+ class RamanDataset(Dataset):
340
+ def __init__(self, data, data_alter, labels=None, transform=None, is_train=True, visualize=False):
341
+ self.data = data
342
+ self.data_alter = data_alter
343
+ self.labels = labels
344
+ self.transform = transform
345
+ self.is_train = is_train
346
+
347
+ def __len__(self):
348
+ return len(self.data)
349
+
350
+ def __getitem__(self, idx):
351
+ spectrum = self.data[idx].astype(np.float32)
352
+ spectrum = spectrum.reshape(1, -1)
353
+ original = torch.from_numpy(spectrum)
354
+ augmented = original.clone()
355
+
356
+ if self.data_alter is None:
357
+ if self.transform and self.is_train:
358
+ augmented = self.transform(augmented)
359
+ if augmented.dim() == 3:
360
+ augmented = augmented.squeeze(0)
361
+ placeholder = torch.tensor([0.0])
362
+ placeholder = torch.tensor([0.0])
363
+
364
+ if self.labels is not None:
365
+ return original, augmented, self.labels[idx]
366
+
367
+ else:
368
+ placeholder = torch.tensor([0.0])
369
+ return original, augmented, placeholder
370
+ else:
371
+ spectrum_raw = self.data_alter[idx].astype(np.float32)
372
+ spectrum_raw = spectrum_raw.reshape(1, -1)
373
+ altered = torch.from_numpy(spectrum_raw)
374
+ spectrum = torch.from_numpy(spectrum)
375
+ if self.labels is not None:
376
+ return altered, spectrum, self.labels[idx]
377
+ else:
378
+ placeholder = torch.tensor([0.0])
379
+ return altered, spectrum, placeholder
380
+
main/__init__.py ADDED
File without changes
main/__pycache__/GEMS.cpython-312.pyc ADDED
Binary file (16.9 kB). View file
 
main/__pycache__/Raman_Task.cpython-312.pyc ADDED
Binary file (46 kB). View file
 
main/__pycache__/Ramandataset.cpython-312.pyc ADDED
Binary file (22.5 kB). View file
 
main/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (187 Bytes). View file
 
main/__pycache__/data_augumentation.cpython-312.pyc ADDED
Binary file (10.3 kB). View file
 
main/__pycache__/evaluate_few_shot_models_fixed_test.cpython-312.pyc ADDED
Binary file (24.1 kB). View file
 
main/__pycache__/evaluate_visualize.cpython-312.pyc ADDED
Binary file (59.1 kB). View file
 
main/__pycache__/hyperpara_optim_contrastive_weight.cpython-312.pyc ADDED
Binary file (20.8 kB). View file
 
main/__pycache__/hyperpara_optim_downstream.cpython-312.pyc ADDED
Binary file (8.4 kB). View file
 
main/__pycache__/hyperpara_optim_pretrain.cpython-312.pyc ADDED
Binary file (11.1 kB). View file
 
main/__pycache__/load_data.cpython-312.pyc ADDED
Binary file (7.05 kB). View file
 
main/__pycache__/pretext.cpython-312.pyc ADDED
Binary file (25.6 kB). View file
 
main/__pycache__/sample_ig_spectrum_check.cpython-312.pyc ADDED
Binary file (15.3 kB). View file
 
main/data_augumentation.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.signal import savgol_filter
3
+ from scipy.interpolate import interp1d
4
+ import matplotlib.pyplot as plt
5
+
6
+
7
+ def augment_minority_classes(spectra, labels, min_samples=30, target_samples=80, verbose=True):
8
+ unique_labels = np.unique(labels)
9
+ class_counts = {label: np.sum(labels == label) for label in unique_labels}
10
+
11
+ minority_classes = [label for label, count in class_counts.items() if count < min_samples]
12
+ if len(minority_classes) == 0:
13
+ return spectra, labels
14
+ augmented_spectra = spectra.copy()
15
+ augmented_labels = labels.copy()
16
+ for minority_class in minority_classes:
17
+ class_samples = spectra[labels == minority_class]
18
+ n_samples = class_samples.shape[0]
19
+ n_augment = target_samples - n_samples
20
+ augmented_samples = []
21
+
22
+ for i in range(n_augment):
23
+ base_idx = np.random.randint(0, n_samples)
24
+ base_sample = class_samples[base_idx].copy()
25
+ augmentation_methods = np.random.choice([
26
+ 'noise', 'baseline', 'intensity', 'shift', 'mixed', 'warp'
27
+ ], size=np.random.randint(1, 4), replace=False)
28
+ augmented_sample = base_sample.copy()
29
+ for method in augmentation_methods:
30
+ if method == 'noise':
31
+ # add Gaussian noise
32
+ noise_level = np.random.uniform(0.001, 0.02)
33
+ noise = np.random.normal(0, noise_level, augmented_sample.shape)
34
+ augmented_sample += noise
35
+
36
+ elif method == 'baseline':
37
+ # add baseline drift
38
+ baseline_shift = np.random.uniform(-0.05, 0.05)
39
+ baseline = np.linspace(0, baseline_shift, len(augmented_sample))
40
+ augmented_sample += baseline
41
+
42
+ elif method == 'intensity':
43
+ # adjust peak intensity
44
+ intensity_factor = np.random.uniform(0.8, 1.2)
45
+ augmented_sample = augmented_sample * intensity_factor
46
+
47
+ elif method == 'shift':
48
+ # slightly shift peak positions (small wavelength shift)
49
+ shift = np.random.randint(-5, 6)
50
+ if shift > 0:
51
+ augmented_sample = np.pad(augmented_sample[:-shift], (shift, 0), mode='edge')
52
+ elif shift < 0:
53
+ augmented_sample = np.pad(augmented_sample[-shift:], (0, -shift), mode='edge')
54
+
55
+ elif method == 'mixed':
56
+ # mix with other samples of the same class (mixup)
57
+ mix_idx = np.random.randint(0, n_samples)
58
+ while mix_idx == base_idx:
59
+ mix_idx = np.random.randint(0, n_samples)
60
+
61
+ mix_ratio = np.random.uniform(0.1, 0.3)
62
+ augmented_sample = (1 - mix_ratio) * augmented_sample + mix_ratio * class_samples[mix_idx]
63
+
64
+ elif method == 'warp':
65
+ n_points = augmented_sample.shape[0]
66
+ knot_points = np.random.choice(range(n_points), size=np.random.randint(4, 8), replace=False)
67
+ knot_points.sort()
68
+ warp_y = augmented_sample[knot_points].copy()
69
+ warp_y += np.random.uniform(-0.05, 0.05, size=len(knot_points))
70
+ warper = interp1d(
71
+ knot_points,
72
+ warp_y,
73
+ kind='cubic',
74
+ bounds_error=False,
75
+ fill_value=(augmented_sample[0], augmented_sample[-1])
76
+ )
77
+ warp_coords = np.arange(n_points)
78
+ augmented_sample = warper(warp_coords)
79
+ augmented_sample = savgol_filter(augmented_sample, window_length=11, polyorder=2)
80
+ if np.max(augmented_sample) - np.min(augmented_sample) > 0:
81
+ augmented_sample = (augmented_sample - np.min(augmented_sample)) / (
82
+ np.max(augmented_sample) - np.min(augmented_sample))
83
+
84
+ augmented_samples.append(augmented_sample)
85
+ augmented_spectra = np.vstack([augmented_spectra, np.array(augmented_samples)])
86
+ augmented_labels = np.append(augmented_labels, np.full(len(augmented_samples), minority_class))
87
+
88
+ return augmented_spectra, augmented_labels
89
+
90
+
91
+ def augment_all_classes_to_target(spectra, labels, target_samples=100, verbose=True):
92
+ """Augment all classes to reach a target sample count per class.
93
+
94
+ Args:
95
+ spectra: input spectra array of shape (n_samples, n_features)
96
+ labels: class labels array of shape (n_samples,)
97
+ target_samples: target number of samples per class (default 100)
98
+ verbose: whether to print augmentation info
99
+
100
+ Returns:
101
+ augmented_spectra: expanded spectra array
102
+ augmented_labels: expanded labels array
103
+ """
104
+ unique_labels = np.unique(labels)
105
+ class_counts = {label: np.sum(labels == label) for label in unique_labels}
106
+
107
+ if verbose:
108
+ print(f"Augmenting all classes to target {target_samples} samples per class")
109
+ print(f" Original class distribution: {class_counts}")
110
+
111
+ augmented_spectra = spectra.copy()
112
+ augmented_labels = labels.copy()
113
+
114
+ for label in unique_labels:
115
+ class_samples = spectra[labels == label]
116
+ n_samples = class_samples.shape[0]
117
+
118
+ # Skip if already at or above target
119
+ if n_samples >= target_samples:
120
+ continue
121
+
122
+ n_augment = target_samples - n_samples
123
+ augmented_samples = []
124
+
125
+ for i in range(n_augment):
126
+ base_idx = np.random.randint(0, n_samples)
127
+ base_sample = class_samples[base_idx].copy()
128
+ augmentation_methods = np.random.choice([
129
+ 'noise', 'baseline', 'intensity', 'shift', 'mixed', 'warp'
130
+ ], size=np.random.randint(1, 4), replace=False)
131
+ augmented_sample = base_sample.copy()
132
+
133
+ for method in augmentation_methods:
134
+ if method == 'noise':
135
+ noise_level = np.random.uniform(0.001, 0.02)
136
+ noise = np.random.normal(0, noise_level, augmented_sample.shape)
137
+ augmented_sample += noise
138
+
139
+ elif method == 'baseline':
140
+ baseline_shift = np.random.uniform(-0.05, 0.05)
141
+ baseline = np.linspace(0, baseline_shift, len(augmented_sample))
142
+ augmented_sample += baseline
143
+
144
+ elif method == 'intensity':
145
+ intensity_factor = np.random.uniform(0.8, 1.2)
146
+ augmented_sample = augmented_sample * intensity_factor
147
+
148
+ elif method == 'shift':
149
+ shift = np.random.randint(-5, 6)
150
+ if shift > 0:
151
+ augmented_sample = np.pad(augmented_sample[:-shift], (shift, 0), mode='edge')
152
+ elif shift < 0:
153
+ augmented_sample = np.pad(augmented_sample[-shift:], (0, -shift), mode='edge')
154
+
155
+ elif method == 'mixed':
156
+ mix_idx = np.random.randint(0, n_samples)
157
+ while mix_idx == base_idx:
158
+ mix_idx = np.random.randint(0, n_samples)
159
+ mix_ratio = np.random.uniform(0.1, 0.3)
160
+ augmented_sample = (1 - mix_ratio) * augmented_sample + mix_ratio * class_samples[mix_idx]
161
+
162
+ elif method == 'warp':
163
+ n_points = augmented_sample.shape[0]
164
+ knot_points = np.random.choice(range(n_points), size=np.random.randint(4, 8), replace=False)
165
+ knot_points.sort()
166
+ warp_y = augmented_sample[knot_points].copy()
167
+ warp_y += np.random.uniform(-0.05, 0.05, size=len(knot_points))
168
+ warper = interp1d(
169
+ knot_points,
170
+ warp_y,
171
+ kind='cubic',
172
+ bounds_error=False,
173
+ fill_value=(augmented_sample[0], augmented_sample[-1])
174
+ )
175
+ warp_coords = np.arange(n_points)
176
+ augmented_sample = warper(warp_coords)
177
+
178
+ # Apply smoothing and normalization
179
+ augmented_sample = savgol_filter(augmented_sample, window_length=11, polyorder=2)
180
+ if np.max(augmented_sample) - np.min(augmented_sample) > 0:
181
+ augmented_sample = (augmented_sample - np.min(augmented_sample)) / (
182
+ np.max(augmented_sample) - np.min(augmented_sample))
183
+
184
+ augmented_samples.append(augmented_sample)
185
+
186
+ augmented_spectra = np.vstack([augmented_spectra, np.array(augmented_samples)])
187
+ augmented_labels = np.append(augmented_labels, np.full(len(augmented_samples), label))
188
+
189
+ if verbose:
190
+ new_class_counts = {label: np.sum(augmented_labels == label) for label in unique_labels}
191
+ print(f" Augmented class distribution: {new_class_counts}")
192
+
193
+ return augmented_spectra, augmented_labels
194
+
195
+
main/evaluate_few_shot_models_fixed_test.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ import re
4
+ from typing import Dict, List, Tuple
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import torch
9
+ from sklearn.metrics import accuracy_score, f1_score
10
+ from sklearn.preprocessing import LabelEncoder
11
+ from torch.utils.data import DataLoader
12
+
13
+ from Raman_Task import (
14
+ load_class_names,
15
+ load_mae_model_for_classification,
16
+ stratified_split_with_minimum_samples,
17
+ )
18
+ from Ramandataset import RamanDataset
19
+ from load_data import load_real_data
20
+
21
+
22
+ def mm_to_inches(mm: float) -> float:
23
+ return mm / 25.4
24
+
25
+
26
+ def setup_plot_style() -> None:
27
+ plt.rcParams.update(
28
+ {
29
+ "font.family": "sans-serif",
30
+ "font.size": 7,
31
+ "axes.labelsize": 8,
32
+ "axes.titlesize": 8,
33
+ "xtick.labelsize": 6,
34
+ "ytick.labelsize": 6,
35
+ "legend.fontsize": 6,
36
+ "axes.linewidth": 0.6,
37
+ "lines.linewidth": 1.2,
38
+ }
39
+ )
40
+
41
+
42
+ def plot_per_model_bar(rows: List[Dict], save_path: str) -> None:
43
+ setup_plot_style()
44
+ labels = [f"{r['samples_per_class']}/c_s{r['seed']}" for r in rows]
45
+ acc = [r["test_accuracy"] for r in rows]
46
+ f1 = [r["test_macro_f1"] for r in rows]
47
+ x = np.arange(len(rows))
48
+ width = 0.42
49
+
50
+ fig, ax = plt.subplots(figsize=(mm_to_inches(170), mm_to_inches(75)), constrained_layout=True)
51
+ ax.bar(x - width / 2, acc, width=width, color="#1f77b4", alpha=0.85, label="Accuracy")
52
+ ax.bar(x + width / 2, f1, width=width, color="#d62728", alpha=0.85, label="Macro-F1")
53
+ ax.set_xticks(x)
54
+ ax.set_xticklabels(labels, rotation=70, ha="right")
55
+ ax.set_ylabel("Score")
56
+ ax.set_xlabel("Model (samples_per_class / seed)")
57
+ ax.set_ylim(0.0, 1.02)
58
+ ax.grid(True, axis="y", linestyle="--", alpha=0.35)
59
+ ax.legend(frameon=False, loc="lower right")
60
+ ax.set_title("Performance of All Fine-Tuned Models on Fixed 100-Test Subset")
61
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
62
+ plt.close(fig)
63
+
64
+
65
+ def plot_mean_std_curve(agg_rows: List[Dict], save_path: str) -> None:
66
+ setup_plot_style()
67
+ x = [r["total_samples"] for r in agg_rows]
68
+ acc_m = [r["acc_mean"] for r in agg_rows]
69
+ acc_s = [r["acc_std"] for r in agg_rows]
70
+ f1_m = [r["macro_f1_mean"] for r in agg_rows]
71
+ f1_s = [r["macro_f1_std"] for r in agg_rows]
72
+
73
+ fig, ax = plt.subplots(figsize=(mm_to_inches(84), mm_to_inches(62)), constrained_layout=True)
74
+ ax.errorbar(x, acc_m, yerr=acc_s, marker="o", capsize=2, color="#1f77b4", label="Accuracy (mean±std)")
75
+ ax.errorbar(x, f1_m, yerr=f1_s, marker="s", capsize=2, color="#d62728", label="Macro-F1 (mean±std)")
76
+ ax.set_xscale("log")
77
+ ax.set_xlabel("Training Set Size (Total Samples)")
78
+ ax.set_ylabel("Score")
79
+ ax.set_ylim(0.0, 1.02)
80
+ ax.grid(True, linestyle="--", alpha=0.35)
81
+ ax.legend(frameon=False, loc="lower right")
82
+ ax.set_title("Few-Shot Performance on Fixed Test Subset")
83
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
84
+ plt.close(fig)
85
+
86
+
87
+ def plot_boxplots(grouped: Dict[int, List[Dict]], save_path: str) -> None:
88
+ setup_plot_style()
89
+ totals = sorted(grouped.keys())
90
+ acc_data = [[r["test_accuracy"] for r in grouped[t]] for t in totals]
91
+ f1_data = [[r["test_macro_f1"] for r in grouped[t]] for t in totals]
92
+ labels = [str(t) for t in totals]
93
+
94
+ fig, axes = plt.subplots(2, 1, figsize=(mm_to_inches(120), mm_to_inches(100)), constrained_layout=True)
95
+
96
+ axes[0].boxplot(acc_data, labels=labels, showmeans=True)
97
+ axes[0].set_ylabel("Accuracy")
98
+ axes[0].set_title("Distribution Across Seeds")
99
+ axes[0].grid(True, axis="y", linestyle="--", alpha=0.35)
100
+
101
+ axes[1].boxplot(f1_data, labels=labels, showmeans=True)
102
+ axes[1].set_ylabel("Macro-F1")
103
+ axes[1].set_xlabel("Training Set Size (Total Samples)")
104
+ axes[1].grid(True, axis="y", linestyle="--", alpha=0.35)
105
+
106
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
107
+ plt.close(fig)
108
+
109
+
110
+ def plot_accuracy_vs_f1_scatter(rows: List[Dict], save_path: str) -> None:
111
+ setup_plot_style()
112
+ totals = sorted({r["total_samples"] for r in rows})
113
+ cmap = plt.get_cmap("viridis")
114
+ color_map = {t: cmap(i / max(1, len(totals) - 1)) for i, t in enumerate(totals)}
115
+
116
+ fig, ax = plt.subplots(figsize=(mm_to_inches(84), mm_to_inches(70)), constrained_layout=True)
117
+ for total in totals:
118
+ items = [r for r in rows if r["total_samples"] == total]
119
+ ax.scatter(
120
+ [r["test_accuracy"] for r in items],
121
+ [r["test_macro_f1"] for r in items],
122
+ s=28,
123
+ alpha=0.85,
124
+ color=color_map[total],
125
+ label=str(total),
126
+ )
127
+
128
+ ax.plot([0, 1], [0, 1], linestyle="--", linewidth=0.8, color="gray", alpha=0.6)
129
+ ax.set_xlim(0.0, 1.02)
130
+ ax.set_ylim(0.0, 1.02)
131
+ ax.set_xlabel("Accuracy")
132
+ ax.set_ylabel("Macro-F1")
133
+ ax.set_title("Model-Wise Accuracy vs Macro-F1")
134
+ ax.grid(True, linestyle="--", alpha=0.35)
135
+ ax.legend(title="Total Samples", frameon=False, ncol=2, loc="lower right")
136
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
137
+ plt.close(fig)
138
+
139
+
140
+ def set_seed(seed: int) -> None:
141
+ np.random.seed(seed)
142
+ torch.manual_seed(seed)
143
+ if torch.cuda.is_available():
144
+ torch.cuda.manual_seed_all(seed)
145
+
146
+
147
+ def sample_fixed_per_class(
148
+ x_data: np.ndarray,
149
+ y_data: np.ndarray,
150
+ samples_per_class: int,
151
+ seed: int,
152
+ ) -> Tuple[np.ndarray, np.ndarray]:
153
+ rng = np.random.default_rng(seed)
154
+ selected_indices = []
155
+
156
+ for cls in np.unique(y_data):
157
+ cls_idx = np.where(y_data == cls)[0]
158
+ if len(cls_idx) < samples_per_class:
159
+ raise ValueError(
160
+ f"Class {cls} has only {len(cls_idx)} samples, cannot sample {samples_per_class}."
161
+ )
162
+ picked = rng.choice(cls_idx, size=samples_per_class, replace=False)
163
+ selected_indices.append(picked)
164
+
165
+ selected_indices = np.concatenate(selected_indices)
166
+ rng.shuffle(selected_indices)
167
+ return x_data[selected_indices], y_data[selected_indices]
168
+
169
+
170
+ def collect_model_runs(study_dir: str) -> List[Dict]:
171
+ pattern = re.compile(r"^total_(\d+)_per_class_(\d+)$")
172
+ seed_pattern = re.compile(r"^seed_(\d+)$")
173
+
174
+ runs = []
175
+ if not os.path.isdir(study_dir):
176
+ return runs
177
+
178
+ for run_name in sorted(os.listdir(study_dir)):
179
+ run_path = os.path.join(study_dir, run_name)
180
+ if not os.path.isdir(run_path):
181
+ continue
182
+
183
+ m = pattern.match(run_name)
184
+ if not m:
185
+ continue
186
+
187
+ total_samples = int(m.group(1))
188
+ per_class = int(m.group(2))
189
+
190
+ for seed_name in sorted(os.listdir(run_path)):
191
+ seed_path = os.path.join(run_path, seed_name)
192
+ if not os.path.isdir(seed_path):
193
+ continue
194
+ ms = seed_pattern.match(seed_name)
195
+ if not ms:
196
+ continue
197
+
198
+ seed = int(ms.group(1))
199
+ model_path = os.path.join(seed_path, "final_model.pth")
200
+ best_model_path = os.path.join(seed_path, "raman_best_class.pth")
201
+
202
+ if os.path.exists(best_model_path):
203
+ load_path = best_model_path
204
+ load_key = "model_state_dict"
205
+ elif os.path.exists(model_path):
206
+ load_path = model_path
207
+ load_key = "model_state_dict"
208
+ else:
209
+ continue
210
+
211
+ runs.append(
212
+ {
213
+ "run_name": run_name,
214
+ "total_samples": total_samples,
215
+ "samples_per_class": per_class,
216
+ "seed": seed,
217
+ "seed_dir": seed_path,
218
+ "model_path": load_path,
219
+ "load_key": load_key,
220
+ }
221
+ )
222
+
223
+ runs.sort(key=lambda x: (x["total_samples"], x["seed"]))
224
+ return runs
225
+
226
+
227
+ def build_reference_test_subset(
228
+ spectra: np.ndarray,
229
+ y_encoded: np.ndarray,
230
+ per_class: int,
231
+ split_seed: int,
232
+ subset_size: int,
233
+ ) -> Tuple[np.ndarray, np.ndarray]:
234
+ x_sub, y_sub = sample_fixed_per_class(
235
+ spectra,
236
+ y_encoded,
237
+ samples_per_class=per_class,
238
+ seed=split_seed + per_class,
239
+ )
240
+
241
+ _, _, x_test, _, _, y_test = stratified_split_with_minimum_samples(
242
+ x_sub,
243
+ y_sub,
244
+ test_size=0.15,
245
+ val_size=0.15,
246
+ min_samples_per_class=1,
247
+ random_state=split_seed,
248
+ )
249
+
250
+ if len(x_test) < subset_size:
251
+ raise ValueError(
252
+ f"Reference test set has only {len(x_test)} samples, requested {subset_size}."
253
+ )
254
+
255
+ rng = np.random.default_rng(split_seed + 999)
256
+ picked = rng.choice(len(x_test), size=subset_size, replace=False)
257
+ return x_test[picked], y_test[picked]
258
+
259
+
260
+ def evaluate_one_model(
261
+ classifier,
262
+ x_test: np.ndarray,
263
+ y_test: np.ndarray,
264
+ device: torch.device,
265
+ batch_size: int = 128,
266
+ ) -> Tuple[float, float]:
267
+ test_dataset = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
268
+ test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=0)
269
+
270
+ all_preds = []
271
+ all_labels = []
272
+
273
+ classifier.eval()
274
+ with torch.no_grad():
275
+ for batch in test_loader:
276
+ inputs, _, labels = batch
277
+ inputs = inputs.to(device)
278
+ labels = labels.to(device).long()
279
+
280
+ logits, _ = classifier(inputs)
281
+ preds = torch.argmax(logits, dim=1)
282
+
283
+ all_preds.append(preds.cpu().numpy())
284
+ all_labels.append(labels.cpu().numpy())
285
+
286
+ y_true = np.concatenate(all_labels)
287
+ y_pred = np.concatenate(all_preds)
288
+
289
+ acc = float(accuracy_score(y_true, y_pred))
290
+ macro_f1 = float(f1_score(y_true, y_pred, average="macro", zero_division=0))
291
+ return acc, macro_f1
292
+
293
+
294
+ def main() -> None:
295
+ set_seed(2026)
296
+
297
+ base_path = "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/"
298
+ data_path = os.path.join(base_path, "bacteria-ID datasets/X_reference_interpolated.npy")
299
+ labels_path = os.path.join(base_path, "bacteria-ID datasets/y_reference.npy")
300
+ wavenumbers_path = os.path.join(base_path, "bacteria-ID datasets/wavenumbers_interpolated_3500.npy")
301
+
302
+ spectra, labels, _ = load_real_data(
303
+ data_path,
304
+ labels_path=labels_path,
305
+ wavenumbers_path=wavenumbers_path,
306
+ normalize=True,
307
+ )
308
+
309
+ mapping_path = os.path.join(base_path, "bacteria-ID datasets/bacteria-ID-label_mapping.json")
310
+ real_class_names = load_class_names(mapping_path)
311
+
312
+ label_encoder = LabelEncoder()
313
+ y_encoded = label_encoder.fit_transform(np.array(labels))
314
+ default_class_names = [str(x) for x in label_encoder.classes_]
315
+ class_names = real_class_names if real_class_names and len(real_class_names) == len(default_class_names) else default_class_names
316
+
317
+ para = {
318
+ "input_length": spectra.shape[1],
319
+ "embedding_dim": 512,
320
+ "num_heads": 16,
321
+ "num_layers": 12,
322
+ "patch_num": 100,
323
+ "lr": 1e-4,
324
+ "mask_ratio": 0.5,
325
+ }
326
+
327
+ pretrained_dir = (
328
+ f"/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/"
329
+ f"{para['lr']}_{para['mask_ratio']}_{para['embedding_dim']}_{para['num_heads']}_{para['num_layers']}_{para['patch_num']}/"
330
+ )
331
+
332
+ study_dir = os.path.join(pretrained_dir, "few_shot_finetune_study")
333
+ runs = collect_model_runs(study_dir)
334
+ if not runs:
335
+ raise FileNotFoundError(f"No model runs found under {study_dir}")
336
+
337
+ largest_per_class = max(r["samples_per_class"] for r in runs)
338
+ reference_seed = 2026
339
+ fixed_test_size = 100
340
+
341
+ ref_x_test, ref_y_test = build_reference_test_subset(
342
+ spectra=spectra,
343
+ y_encoded=y_encoded,
344
+ per_class=largest_per_class,
345
+ split_seed=reference_seed,
346
+ subset_size=fixed_test_size,
347
+ )
348
+
349
+ eval_dir = os.path.join(study_dir, "fixed_100_test_evaluation")
350
+ os.makedirs(eval_dir, exist_ok=True)
351
+ np.savez(
352
+ os.path.join(eval_dir, "reference_test_subset.npz"),
353
+ x_test=ref_x_test,
354
+ y_test=ref_y_test,
355
+ class_names=np.array(class_names, dtype=object),
356
+ )
357
+
358
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
359
+
360
+ rows = []
361
+ for run in runs:
362
+ classifier, _, _, _ = load_mae_model_for_classification(
363
+ pretrained_path=os.path.join(pretrained_dir, "Finetuned_modified/Fine_tuned.pth"),
364
+ input_length=para["input_length"],
365
+ patch_num=para["patch_num"],
366
+ embedding_dim=para["embedding_dim"],
367
+ num_layers=para["num_layers"],
368
+ num_heads=para["num_heads"],
369
+ num_classes=len(class_names),
370
+ device=device,
371
+ )
372
+
373
+ checkpoint = torch.load(run["model_path"], map_location=device)
374
+ classifier.load_state_dict(checkpoint[run["load_key"]])
375
+
376
+ acc, macro_f1 = evaluate_one_model(
377
+ classifier=classifier,
378
+ x_test=ref_x_test,
379
+ y_test=ref_y_test,
380
+ device=device,
381
+ batch_size=128,
382
+ )
383
+
384
+ row = {
385
+ "run_name": run["run_name"],
386
+ "total_samples": run["total_samples"],
387
+ "samples_per_class": run["samples_per_class"],
388
+ "seed": run["seed"],
389
+ "model_path": run["model_path"],
390
+ "test_size": fixed_test_size,
391
+ "test_accuracy": acc,
392
+ "test_macro_f1": macro_f1,
393
+ }
394
+ rows.append(row)
395
+ print(
396
+ f"[{run['run_name']}/seed_{run['seed']}] "
397
+ f"acc={acc:.4f}, macro_f1={macro_f1:.4f}"
398
+ )
399
+
400
+ rows.sort(key=lambda x: (x["total_samples"], x["seed"]))
401
+
402
+ csv_path = os.path.join(eval_dir, "all_models_fixed100_results.csv")
403
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
404
+ writer = csv.DictWriter(
405
+ f,
406
+ fieldnames=[
407
+ "run_name",
408
+ "total_samples",
409
+ "samples_per_class",
410
+ "seed",
411
+ "model_path",
412
+ "test_size",
413
+ "test_accuracy",
414
+ "test_macro_f1",
415
+ ],
416
+ )
417
+ writer.writeheader()
418
+ writer.writerows(rows)
419
+
420
+ # Aggregate by sample size for quick comparison
421
+ grouped: Dict[int, List[Dict]] = {}
422
+ for row in rows:
423
+ grouped.setdefault(row["total_samples"], []).append(row)
424
+
425
+ agg_path = os.path.join(eval_dir, "all_models_fixed100_results_agg.csv")
426
+ agg_rows = []
427
+ with open(agg_path, "w", newline="", encoding="utf-8") as f:
428
+ writer = csv.DictWriter(
429
+ f,
430
+ fieldnames=[
431
+ "total_samples",
432
+ "samples_per_class",
433
+ "runs",
434
+ "acc_mean",
435
+ "acc_std",
436
+ "macro_f1_mean",
437
+ "macro_f1_std",
438
+ ],
439
+ )
440
+ writer.writeheader()
441
+ for total_samples in sorted(grouped.keys()):
442
+ items = grouped[total_samples]
443
+ accs = np.array([x["test_accuracy"] for x in items], dtype=np.float64)
444
+ f1s = np.array([x["test_macro_f1"] for x in items], dtype=np.float64)
445
+ row = {
446
+ "total_samples": total_samples,
447
+ "samples_per_class": items[0]["samples_per_class"],
448
+ "runs": len(items),
449
+ "acc_mean": float(accs.mean()),
450
+ "acc_std": float(accs.std(ddof=0)),
451
+ "macro_f1_mean": float(f1s.mean()),
452
+ "macro_f1_std": float(f1s.std(ddof=0)),
453
+ }
454
+ agg_rows.append(row)
455
+ writer.writerow(row)
456
+
457
+ plot_per_model_bar(rows, os.path.join(eval_dir, "viz_all_models_bar.png"))
458
+ plot_mean_std_curve(agg_rows, os.path.join(eval_dir, "viz_mean_std_curve_84mm.png"))
459
+ plot_boxplots(grouped, os.path.join(eval_dir, "viz_boxplot_by_samples.png"))
460
+ plot_accuracy_vs_f1_scatter(rows, os.path.join(eval_dir, "viz_acc_vs_f1_scatter_84mm.png"))
461
+
462
+ print("=" * 80)
463
+ print(f"Saved per-model results: {csv_path}")
464
+ print(f"Saved aggregated results: {agg_path}")
465
+ print(f"Reference test subset saved to: {os.path.join(eval_dir, 'reference_test_subset.npz')}")
466
+ print(f"Saved figures under: {eval_dir}")
467
+
468
+
469
+ if __name__ == "__main__":
470
+ main()
main/evaluate_visualize.py ADDED
@@ -0,0 +1,1270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import matplotlib
3
+ matplotlib.use('Agg')
4
+ import matplotlib.pyplot as plt
5
+ import seaborn as sns
6
+ import os
7
+ import sys
8
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9
+ from main.GEMS import MaskedAutoencoderRaman
10
+ from sklearn.metrics import confusion_matrix, classification_report, roc_curve, auc
11
+ from sklearn.ensemble import IsolationForest
12
+ from sklearn.preprocessing import label_binarize
13
+ import torch
14
+ from mpl_toolkits.axes_grid1.inset_locator import mark_inset
15
+ from sklearn.metrics.pairwise import cosine_distances
16
+ from scipy import signal
17
+ from sklearn.manifold import TSNE
18
+
19
+ from sklearn.decomposition import PCA
20
+ plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'SimHei', 'DejaVu Sans']
21
+ plt.rcParams['axes.unicode_minus'] = False
22
+
23
+ size = {"single": 84, "double": 170}
24
+
25
+ def mm_to_inches(mm):
26
+ return mm / 25.4
27
+
28
+ def generate_snr_report(snr_results, save_dir, status):
29
+ methods = ['peak_to_noise', 'rms', 'mad', 'baseline_corrected', 'multi_peak']
30
+ spectrum_types = ['pure_snr', 'reconstructed_snr']
31
+ stats = {}
32
+ for spectrum_type in spectrum_types:
33
+ stats[spectrum_type] = {}
34
+ for method in methods:
35
+ snr_values = []
36
+ for result in snr_results:
37
+ snr_db = result[spectrum_type].get(method, {}).get('snr_db', np.nan)
38
+ if not np.isnan(snr_db) and snr_db != float('inf'):
39
+ snr_values.append(snr_db)
40
+
41
+ if snr_values:
42
+ stats[spectrum_type][method] = {
43
+ 'mean': np.mean(snr_values),
44
+ 'std': np.std(snr_values),
45
+ 'median': np.median(snr_values),
46
+ 'min': np.min(snr_values),
47
+ 'max': np.max(snr_values),
48
+ 'count': len(snr_values)
49
+ }
50
+ else:
51
+ stats[spectrum_type][method] = {
52
+ 'mean': np.nan, 'std': np.nan, 'median': np.nan,
53
+ 'min': np.nan, 'max': np.nan, 'count': 0
54
+ }
55
+ if status == 'finetune':
56
+ report_filename = 'fine_tune_snr_report.txt'
57
+ elif status == 'pretrain':
58
+ report_filename = 'pretrain_snr_report.txt'
59
+ elif status == 'downtask':
60
+ report_filename = 'downstream_snr_report.txt'
61
+
62
+ report_path = os.path.join(save_dir, report_filename)
63
+
64
+ with open(report_path, 'w', encoding='utf-8') as f:
65
+ f.write("=" * 60 + "\n")
66
+ f.write("raman spectra (SNR) analysis report\n")
67
+ f.write("=" * 60 + "\n\n")
68
+
69
+ f.write(f"Number of samples analyzed: {len(snr_results)}\n")
70
+ if status == 'fine_tune':
71
+ f.write(f"Training phase: {'Fine-tuning'}\n\n")
72
+ elif status == 'pretrain':
73
+ f.write(f"Training phase: {'Pre-training'}\n\n")
74
+ elif status == 'downtask':
75
+ f.write(f"Training phase: {'Downstream task'}\n\n")
76
+ # Comparison table
77
+ f.write("Statistics of various SNR calculation methods (dB):\n")
78
+ f.write("-" * 80 + "\n")
79
+ f.write(f"{'Method':<20} {'Spectrum Type':<15} {'Mean':<8} {'Std Dev':<8} {'Median':<8} {'Min':<8} {'Max':<8}\n")
80
+ f.write("-" * 80 + "\n")
81
+
82
+ for method in methods:
83
+ for i, spectrum_type in enumerate(spectrum_types):
84
+ type_name = {'pure_snr': 'Pure', 'reconstructed_snr': 'Reconstructed'}[spectrum_type]
85
+ stat = stats[spectrum_type][method]
86
+
87
+ method_name = method if i == 0 else ""
88
+ f.write(f"{method_name:<20} {type_name:<15} {stat['mean']:<8.2f} {stat['std']:<8.2f} "
89
+ f"{stat['median']:<8.2f} {stat['min']:<8.2f} {stat['max']:<8.2f}\n")
90
+ f.write("-" * 80 + "\n")
91
+
92
+ # SNR improvement analysis
93
+ f.write("\nSNR improvement analysis:\n")
94
+ f.write("-" * 40 + "\n")
95
+
96
+ for method in methods:
97
+ orig_mean = stats['pure_snr'][method]['mean']
98
+ recon_mean = stats['reconstructed_snr'][method]['mean']
99
+
100
+ if not any(np.isnan([orig_mean, recon_mean])):
101
+ recon_improvement = recon_mean - orig_mean
102
+
103
+ f.write(f"{method}:\n")
104
+ f.write(f" Reconstruction relative to original: {recon_improvement:+.2f} dB\n")
105
+
106
+ print(f"SNR analysis report saved to {report_path}")
107
+
108
+ # Generate SNR comparison charts
109
+ plot_snr_comparison(stats, save_dir, status)
110
+
111
+ def plot_snr_comparison(stats, save_dir, status):
112
+ methods = ['peak_to_noise', 'rms', 'mad', 'baseline_corrected', 'multi_peak']
113
+ spectrum_types = ['pure_snr', 'reconstructed_snr']
114
+ type_labels = ['Pure', 'Reconstructed']
115
+ colors = ['blue', 'red']
116
+
117
+ fig, axes = plt.subplots(2, 3, figsize=(18, 12))
118
+ axes = axes.flatten()
119
+
120
+ for i, method in enumerate(methods):
121
+ ax = axes[i]
122
+
123
+ means = []
124
+ stds = []
125
+ labels = []
126
+
127
+ for j, spectrum_type in enumerate(spectrum_types):
128
+ stat = stats[spectrum_type][method]
129
+ if not np.isnan(stat['mean']):
130
+ means.append(stat['mean'])
131
+ stds.append(stat['std'])
132
+ labels.append(type_labels[j])
133
+
134
+ if means:
135
+ x = np.arange(len(labels))
136
+ bars = ax.bar(x, means, yerr=stds, capsize=5, alpha=0.7,
137
+ color=[colors[spectrum_types.index(st + '_snr')] for st in
138
+ ['pure', 'reconstructed'] if st + '_snr' in
139
+ [spectrum_types[k] for k in range(len(labels))]])
140
+
141
+ ax.set_title(f'{method.replace("_", " ").title()} SNR', fontsize=12)
142
+ ax.set_ylabel('SNR (dB)')
143
+ ax.set_xticks(x)
144
+ ax.set_xticklabels(labels, rotation=45)
145
+ ax.grid(True, alpha=0.3)
146
+
147
+ for bar, mean, std in zip(bars, means, stds):
148
+ height = bar.get_height()
149
+ ax.text(bar.get_x() + bar.get_width()/2., height + std + 0.5,
150
+ f'{mean:.1f}', ha='center', va='bottom', fontsize=10)
151
+
152
+ if len(methods) < len(axes):
153
+ for i in range(len(methods), len(axes)):
154
+ fig.delaxes(axes[i])
155
+
156
+ plt.tight_layout()
157
+
158
+ if status == 'finetune':
159
+ chart_filename = 'fine_tune_snr_comparison.png'
160
+ elif status == 'pretrain':
161
+ chart_filename = 'pretrain_snr_comparison.png'
162
+ elif status == 'downtask':
163
+ chart_filename = 'downstream_snr_comparison.png'
164
+ chart_path = os.path.join(save_dir, chart_filename)
165
+ plt.savefig(chart_path, dpi=300, bbox_inches='tight')
166
+ plt.close()
167
+
168
+ print(f"SNR Comparison chart saved to {chart_path}")
169
+
170
+
171
+ def visualize_transformed_and_reconstructed(model, status, test_dataset, wavenumbers, device, save_dir, num_samples=100):
172
+ model.eval()
173
+ os.makedirs(save_dir, exist_ok=True)
174
+ indices = np.random.choice(len(test_dataset), num_samples, replace=False)
175
+ all_snr_results = []
176
+ rows, cols = 4, 4
177
+ n_plots = rows * cols
178
+ plt.figure(figsize=(16, 12))
179
+ plot_indices = indices[:n_plots]
180
+ fig, axes = plt.subplots(rows, cols, figsize=(16, 12))
181
+ axes = axes.flatten()
182
+
183
+ for ax in axes[len(plot_indices):]:
184
+ ax.axis('off')
185
+
186
+ for i, idx in enumerate(plot_indices):
187
+ ax = axes[i]
188
+ data_item = test_dataset[idx]
189
+ augumented_spectra = data_item[1].unsqueeze(0).to(device)
190
+ processed_spectra = data_item[0].unsqueeze(0).to(device)
191
+ mask_ratio = 0.5
192
+ with torch.no_grad():
193
+ reconstructed, embedding, mask, loss = model(processed_spectra, mask_ratio=mask_ratio, tgt=processed_spectra)
194
+
195
+ reconstructed = reconstructed.view(augumented_spectra.size(0), -1) # (1, signal_length)
196
+
197
+ original_np = processed_spectra.cpu().squeeze().numpy()
198
+ reconstructed_np = reconstructed.cpu().squeeze().numpy()
199
+ processed_np = augumented_spectra.cpu().squeeze().numpy()
200
+ original_snr = comprehensive_snr_analysis(original_np)
201
+ reconstructed_snr = comprehensive_snr_analysis(reconstructed_np)
202
+ sample_results = {
203
+ 'sample_idx': idx,
204
+ 'mask_ratio': mask_ratio,
205
+ 'pure_snr': original_snr,
206
+ 'reconstructed_snr': reconstructed_snr
207
+ }
208
+ all_snr_results.append(sample_results)
209
+ color1 = plt.cm.tab20c.colors[0]
210
+ color2 = plt.cm.tab20c.colors[4]
211
+
212
+ ax.plot(wavenumbers[400:1800], original_np[400:1800], label='Processed', linewidth=1.5, alpha=0.4, color=color2)
213
+ ax.plot(wavenumbers[400:1800], reconstructed_np[400:1800], label=f'Reconstructed (mask={mask_ratio:.2f})', linewidth=1, alpha=0.9, color=color1)
214
+
215
+ orig_snr_peak = original_snr.get('peak_to_noise', {}).get('snr_db', np.nan)
216
+ recon_snr_peak = reconstructed_snr.get('peak_to_noise', {}).get('snr_db', np.nan)
217
+
218
+ title = f'Sample {i + 1} - Pure: {orig_snr_peak:.1f} dB | Recon: {recon_snr_peak:.1f} dB'
219
+ ax.set_title(title, fontsize=9)
220
+ ax.set_ylabel('Intensity', fontsize=8)
221
+ ax.grid(True, alpha=0.3)
222
+ ax.legend(fontsize=7)
223
+
224
+ if i // cols == rows - 1:
225
+ ax.set_xlabel('Raman Shift (cm-1)', fontsize=8)
226
+ else:
227
+ ax.set_xticklabels([])
228
+
229
+ plt.tight_layout()
230
+
231
+ if status == 'finetune':
232
+ save_path = os.path.join(save_dir, 'fine_tune_spectrum_snr_comparison.png')
233
+ elif status == 'pretrain':
234
+ save_path = os.path.join(save_dir, 'spectrum_snr_comparison.png')
235
+ elif status == 'downtask':
236
+ save_path = os.path.join(save_dir, 'downstream_spectrum_snr_comparison.png')
237
+ # plt.show()
238
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
239
+ plt.close()
240
+
241
+ generate_snr_report(all_snr_results, save_dir, status)
242
+
243
+ print(f"spectral SNR comparison saved to {save_path}")
244
+
245
+ return all_snr_results
246
+
247
+ def load_and_visualize_mae_model(model_path, status, test_dataset, device, save_dir, input_length, wavenumbers, patch_num=100,
248
+ embedding_dim=128, num_heads=16, num_layers=12):
249
+
250
+ if status == "fine_tune":
251
+ model_path = os.path.join(model_path, 'Fine_tuned.pth')
252
+ elif status == "pretrain":
253
+ model_path = os.path.join(model_path, 'Pretexted.pth')
254
+ elif status == "downtask":
255
+ model_path = model_path
256
+ print(f"loading model: {model_path}")
257
+
258
+ mae_model = MaskedAutoencoderRaman(
259
+ input_length=input_length,
260
+ patch_num=patch_num,
261
+ embed_dim=embedding_dim,
262
+ depth=num_layers,
263
+ num_heads=num_heads,
264
+ decoder_embed_dim=embedding_dim // 2,
265
+ decoder_depth=4,
266
+ decoder_num_heads=num_heads // 2
267
+ ).to(device)
268
+
269
+ checkpoint = torch.load(model_path, map_location=device)
270
+
271
+ # Load model weights based on checkpoint structure
272
+ if 'model_state_dict' in checkpoint:
273
+ mae_model.load_state_dict(checkpoint['model_state_dict'])
274
+ print("✅ Successfully loaded pretrained model weights")
275
+ elif 'state_dict' in checkpoint:
276
+ mae_model.load_state_dict(checkpoint['state_dict'])
277
+ print("✅ Successfully loaded pretrained model weights")
278
+ else:
279
+ # If the checkpoint directly contains model weights
280
+ mae_model.load_state_dict(checkpoint)
281
+ print("✅ Successfully loaded pretrained model weights")
282
+ mae_model.eval()
283
+ print("Generating visualization results...")
284
+ visualize_transformed_and_reconstructed(mae_model, status, test_dataset, wavenumbers, device, save_dir)
285
+
286
+ return mae_model
287
+
288
+
289
+ def calculate_snr_methods(spectrum, method='peak_to_noise'):
290
+ if isinstance(spectrum, torch.Tensor):
291
+ spectrum = spectrum.detach().cpu().numpy()
292
+
293
+ spectrum = spectrum.flatten()
294
+
295
+ if method == 'peak_to_noise':
296
+ peaks, properties = signal.find_peaks(spectrum, height=np.mean(spectrum) + 2*np.std(spectrum))
297
+
298
+ if len(peaks) > 0:
299
+ max_peak_idx = peaks[np.argmax(spectrum[peaks])]
300
+ signal_intensity = spectrum[max_peak_idx]
301
+ baseline_mask = spectrum < np.percentile(spectrum, 25)
302
+ if np.sum(baseline_mask) > 10:
303
+ noise_level = np.std(spectrum[baseline_mask])
304
+ else:
305
+ noise_level = np.std(spectrum) * 0.1
306
+
307
+ if noise_level > 0:
308
+ snr_linear = signal_intensity / noise_level
309
+ snr_db = 20 * np.log10(snr_linear)
310
+ else:
311
+ snr_db = float('inf')
312
+
313
+ details = {
314
+ 'signal_intensity': signal_intensity,
315
+ 'noise_level': noise_level,
316
+ 'peak_position': max_peak_idx,
317
+ 'num_peaks': len(peaks)
318
+ }
319
+ else:
320
+ snr_db = 0
321
+ details = {'error': 'No peaks found'}
322
+
323
+ elif method == 'rms':
324
+ signal_rms = np.sqrt(np.mean(spectrum**2))
325
+ spectrum_smooth = signal.savgol_filter(spectrum,
326
+ window_length=min(51, len(spectrum)//10*2+1),
327
+ polyorder=3)
328
+ noise = spectrum - spectrum_smooth
329
+ noise_rms = np.sqrt(np.mean(noise**2))
330
+ if noise_rms > 0:
331
+ snr_linear = signal_rms / noise_rms
332
+ snr_db = 20 * np.log10(snr_linear)
333
+ else:
334
+ snr_db = float('inf')
335
+
336
+ details = {
337
+ 'signal_rms': signal_rms,
338
+ 'noise_rms': noise_rms
339
+ }
340
+
341
+ elif method == 'mad':
342
+ median_intensity = np.median(spectrum)
343
+ mad = np.median(np.abs(spectrum - median_intensity))
344
+ signal_intensity = np.max(spectrum)
345
+ noise_level = 1.4826 * mad
346
+
347
+ if noise_level > 0:
348
+ snr_linear = (signal_intensity - median_intensity) / noise_level
349
+ snr_db = 20 * np.log10(snr_linear)
350
+ else:
351
+ snr_db = float('inf')
352
+
353
+ details = {
354
+ 'signal_intensity': signal_intensity,
355
+ 'median_intensity': median_intensity,
356
+ 'mad': mad,
357
+ 'noise_level': noise_level
358
+ }
359
+
360
+ elif method == 'baseline_corrected':
361
+ x = np.arange(len(spectrum))
362
+ baseline_points = []
363
+ window_size = len(spectrum) // 20
364
+
365
+ for i in range(0, len(spectrum), window_size):
366
+ end_idx = min(i + window_size, len(spectrum))
367
+ window = spectrum[i:end_idx]
368
+ baseline_points.append(np.percentile(window, 5)) # 5th percentile as baseline
369
+
370
+ baseline_x = np.linspace(0, len(spectrum)-1, len(baseline_points))
371
+ baseline = np.interp(x, baseline_x, baseline_points)
372
+ corrected_spectrum = spectrum - baseline
373
+ signal_power = np.mean(corrected_spectrum[corrected_spectrum > 0]**2)
374
+ noise_power = np.mean(corrected_spectrum[corrected_spectrum <= np.percentile(corrected_spectrum, 20)]**2)
375
+
376
+ if noise_power > 0:
377
+ snr_linear = signal_power / noise_power
378
+ snr_db = 10 * np.log10(snr_linear)
379
+ else:
380
+ snr_db = float('inf')
381
+
382
+ details = {
383
+ 'signal_power': signal_power,
384
+ 'noise_power': noise_power,
385
+ 'baseline_corrected': True
386
+ }
387
+
388
+ elif method == 'multi_peak':
389
+ peaks, properties = signal.find_peaks(spectrum,
390
+ height=np.mean(spectrum) + np.std(spectrum),
391
+ distance=len(spectrum)//50)
392
+
393
+ if len(peaks) >= 2:
394
+ top_peaks = peaks[np.argsort(spectrum[peaks])[-3:]]
395
+
396
+ snr_values = []
397
+ for peak_idx in top_peaks:
398
+ start_idx = max(0, peak_idx - 20)
399
+ end_idx = min(len(spectrum), peak_idx + 20)
400
+ local_region = spectrum[start_idx:end_idx]
401
+
402
+ signal_intensity = spectrum[peak_idx]
403
+ local_baseline = np.percentile(local_region, 10)
404
+ local_noise = np.std(local_region[local_region < local_baseline + np.std(local_region)])
405
+
406
+ if local_noise > 0:
407
+ local_snr = (signal_intensity - local_baseline) / local_noise
408
+ snr_values.append(20 * np.log10(local_snr))
409
+
410
+ snr_db = np.mean(snr_values) if snr_values else 0
411
+
412
+ details = {
413
+ 'num_peaks_analyzed': len(top_peaks),
414
+ 'individual_snrs': snr_values,
415
+ 'peak_positions': top_peaks.tolist()
416
+ }
417
+ else:
418
+ return calculate_snr_methods(spectrum, method='peak_to_noise')
419
+
420
+ else:
421
+ raise ValueError(f"Unknown SNR calculation method: {method}")
422
+
423
+ return snr_db, details
424
+
425
+ def comprehensive_snr_analysis(spectrum):
426
+ methods = ['peak_to_noise', 'rms', 'mad', 'baseline_corrected', 'multi_peak']
427
+ snr_results = {}
428
+
429
+ for method in methods:
430
+ try:
431
+ snr_db, details = calculate_snr_methods(spectrum, method=method)
432
+ snr_results[method] = {
433
+ 'snr_db': snr_db,
434
+ 'details': details
435
+ }
436
+ except Exception as e:
437
+ snr_results[method] = {
438
+ 'snr_db': np.nan,
439
+ 'details': {'error': str(e)}
440
+ }
441
+
442
+ return snr_results
443
+
444
+ def remove_class_outliers(embeddings, labels,
445
+ class_threshold=2.5,
446
+ enable_global_filter=True,
447
+ contamination=0.02):
448
+
449
+ if enable_global_filter:
450
+ print("Running Global Isolation Forest...")
451
+ iso = IsolationForest(contamination=contamination, random_state=42, n_jobs=-1)
452
+ global_mask = iso.fit_predict(embeddings) == 1
453
+
454
+ embeddings = embeddings[global_mask]
455
+ labels = labels[global_mask]
456
+ print(f"Global filtering removed {np.sum(~global_mask)} samples.")
457
+
458
+ cleaned_embeds, cleaned_labels = [], []
459
+
460
+ for cls in np.unique(labels):
461
+ mask = labels == cls
462
+ cls_embed = embeddings[mask]
463
+ if len(cls_embed) < 5:
464
+ cleaned_embeds.append(cls_embed)
465
+ cleaned_labels.append(labels[mask])
466
+ continue
467
+
468
+ center = cls_embed.mean(axis=0, keepdims=True)
469
+ dist = cosine_distances(cls_embed, center).ravel()
470
+ median_dist = np.median(dist)
471
+ mad = np.median(np.abs(dist - median_dist))
472
+
473
+ if mad == 0:
474
+ keep = np.ones(len(dist), dtype=bool)
475
+ else:
476
+ mod_z_score = 0.6745 * (dist - median_dist) / mad
477
+ keep = mod_z_score < class_threshold
478
+
479
+ cleaned_embeds.append(cls_embed[keep])
480
+ cleaned_labels.append(labels[mask][keep])
481
+
482
+ return np.vstack(cleaned_embeds), np.concatenate(cleaned_labels)
483
+
484
+ def plot_tsne_embeddings(embeddings, labels, class_names=None, title=None, save_path=None,
485
+ perplexity=30, max_iter=1000,
486
+ figsize=(mm_to_inches(84), mm_to_inches(70)),
487
+ outlier_z=2.5):
488
+
489
+ embeddings, labels = remove_class_outliers(embeddings, labels, class_threshold=outlier_z)
490
+ print(f"processing t-SNE... (perplexity={perplexity}, max_iter={max_iter})...")
491
+
492
+ tsne = TSNE(
493
+ n_components=2,
494
+ perplexity=min(perplexity, len(embeddings)//4),
495
+ max_iter=max_iter,
496
+ random_state=42,
497
+ learning_rate=400.0,
498
+ early_exaggeration=20.0,
499
+ init='pca',
500
+ n_iter_without_progress=300,
501
+ method='barnes_hut',
502
+ angle=0.3
503
+ )
504
+
505
+ if isinstance(embeddings, torch.Tensor):
506
+ embeddings = embeddings.cpu().numpy()
507
+ if isinstance(labels, torch.Tensor):
508
+ labels = labels.cpu().numpy()
509
+
510
+ from sklearn.preprocessing import StandardScaler
511
+ scaler = StandardScaler()
512
+ embeddings_scaled = scaler.fit_transform(embeddings)
513
+
514
+ n_samples, n_features = embeddings_scaled.shape
515
+ n_components = min(50, n_samples, n_features)
516
+
517
+ if n_features > 50 and n_samples > n_components:
518
+ pca = PCA(n_components=n_components, random_state=42)
519
+ embeddings_proc = pca.fit_transform(embeddings_scaled)
520
+ else:
521
+ embeddings_proc = embeddings_scaled
522
+
523
+ effective_perplexity = min(max(perplexity, 5), max(5, len(embeddings_proc) // 10))
524
+
525
+ tsne = TSNE(
526
+ n_components=2,
527
+ perplexity=effective_perplexity,
528
+ max_iter=max(max_iter, 2000),
529
+ random_state=42,
530
+ learning_rate=800.0,
531
+ early_exaggeration=36.0,
532
+ init='pca',
533
+ n_iter_without_progress=500,
534
+ method='barnes_hut',
535
+ angle=0.3
536
+ )
537
+
538
+ try:
539
+ embeddings_2d = tsne.fit_transform(embeddings_proc)
540
+ print("Completed t-SNE dimensionality reduction")
541
+ except Exception as e:
542
+ print(f"t-SNE dimensionality reduction failed: {e}")
543
+ pca_fallback = PCA(n_components=2, random_state=42)
544
+ embeddings_2d = pca_fallback.fit_transform(embeddings_proc)
545
+ print("PCA dimensionality reduction completed")
546
+
547
+ plt.rcParams.update({
548
+ 'font.size': 6,
549
+ 'axes.labelsize': 6,
550
+ 'axes.titlesize': 7,
551
+ 'xtick.labelsize': 5,
552
+ 'ytick.labelsize': 5,
553
+ 'legend.fontsize': 5,
554
+ 'lines.linewidth': 0.4,
555
+ 'axes.linewidth': 0.4,
556
+ 'grid.linewidth': 0.3,
557
+ 'xtick.major.width': 0.4,
558
+ 'ytick.major.width': 0.4,
559
+ 'font.family': 'sans-serif'
560
+ })
561
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
562
+
563
+ unique_labels = np.unique(labels)
564
+ colors = plt.cm.tab20(np.linspace(0, 1, len(unique_labels)))
565
+ if class_names is None:
566
+ class_names = [f"class_{i}" for i in unique_labels]
567
+
568
+ legend_handles = []
569
+ legend_labels_list = []
570
+
571
+ for i, label in enumerate(unique_labels):
572
+ mask = labels == label
573
+ if isinstance(label, (int, np.integer)):
574
+ if class_names is not None and 0 <= label < len(class_names):
575
+ class_name = class_names[label]
576
+ else:
577
+ class_name = f"class_{label}"
578
+ else:
579
+ # If label is not an integer (e.g. string), use it directly
580
+ class_name = str(label)
581
+
582
+ sc = ax.scatter(
583
+ embeddings_2d[mask, 0],
584
+ embeddings_2d[mask, 1],
585
+ c=[colors[i]],
586
+ label=class_name,
587
+ alpha=0.8,
588
+ s=3,
589
+ edgecolors='none',
590
+ )
591
+ legend_handles.append(sc)
592
+ legend_labels_list.append(class_name)
593
+
594
+ for i, label in enumerate(unique_labels):
595
+ mask = labels == label
596
+ center_x = np.mean(embeddings_2d[mask, 0])
597
+ center_y = np.mean(embeddings_2d[mask, 1])
598
+
599
+ if label < len(class_names):
600
+ class_name = class_names[label]
601
+ else:
602
+ class_name = f"class_{label}"
603
+
604
+ if title: ax.set_title(title, pad=3)
605
+ ax.set_xlabel('Dim 1', labelpad=1)
606
+ ax.set_ylabel('Dim 2', labelpad=1)
607
+ ax.tick_params(axis='both', which='major', pad=1, length=2)
608
+ ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.3)
609
+
610
+ if save_path:
611
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
612
+ print(f"t-SNE saved: {save_path}")
613
+ # plt.show()
614
+ plt.close(fig)
615
+
616
+ if save_path:
617
+ dir_name, file_name = os.path.split(save_path)
618
+ name_root, ext = os.path.splitext(file_name)
619
+ legend_save_path = os.path.join(dir_name, f"{name_root}_legend{ext}")
620
+ fig_leg = plt.figure(figsize=(3, 3))
621
+ ax_leg = fig_leg.add_subplot(111)
622
+ ax_leg.axis('off')
623
+ n_classes = len(unique_labels)
624
+ n_cols = 4 if n_classes > 12 else (3 if n_classes > 6 else 1)
625
+ leg = ax_leg.legend(
626
+ legend_handles,
627
+ legend_labels_list,
628
+ loc='center',
629
+ ncol=n_cols,
630
+ fontsize=7,
631
+ markerscale=3.0,
632
+ handletextpad=0.5,
633
+ columnspacing=1.0
634
+ )
635
+ fig_leg.savefig(legend_save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
636
+ print(f"Legend saved separately: {legend_save_path}")
637
+ plt.close(fig_leg)
638
+
639
+ # plt.show()
640
+
641
+ return embeddings_2d
642
+
643
+ def plot_umap_embeddings(embeddings, labels, class_names=None, save_path=None,
644
+ n_neighbors=15, min_dist=0.1, figsize=(mm_to_inches(42), mm_to_inches(35))):
645
+ from umap.umap_ import UMAP
646
+
647
+ import matplotlib.pyplot as plt
648
+ import numpy as np
649
+
650
+ print(f"Starting UMAP dimensionality reduction (n_neighbors={n_neighbors}, min_dist={min_dist})...")
651
+
652
+ reducer = UMAP(
653
+ n_components=2,
654
+ n_neighbors=n_neighbors,
655
+ min_dist=min_dist,
656
+ random_state=42
657
+ )
658
+
659
+ if isinstance(embeddings, torch.Tensor):
660
+ embeddings = embeddings.cpu().numpy()
661
+ if isinstance(labels, torch.Tensor):
662
+ labels = labels.cpu().numpy()
663
+
664
+ try:
665
+ embeddings_2d = reducer.fit_transform(embeddings)
666
+ print("UMAP dimensionality reduction completed")
667
+ except Exception as e:
668
+ print(f"UMAP dimensionality reduction failed: {e}")
669
+ return None
670
+
671
+ # Create visualization (same plotting logic as t-SNE)
672
+ plt.rcParams.update({
673
+ 'font.size': 6, # Global base font size
674
+ 'axes.labelsize': 6, # Axis labels
675
+ 'xtick.labelsize': 5, # Tick label size
676
+ 'ytick.labelsize': 5,
677
+ 'font.family': 'sans-serif',
678
+ 'lines.linewidth': 0.4,
679
+ 'axes.linewidth': 0.4, # Thinner axis frame lines
680
+ 'grid.linewidth': 0.3
681
+ })
682
+
683
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
684
+ unique_labels = np.unique(labels)
685
+ colors = plt.cm.tab20(np.linspace(0, 1, len(unique_labels)))
686
+ legend_handles = []
687
+ legend_labels_list = []
688
+ for i, label in enumerate(unique_labels):
689
+ mask = labels == label
690
+ if hasattr(class_names, '__getitem__') and label < len(class_names):
691
+ c_name = class_names[label]
692
+ else:
693
+ c_name = f"Class {label}"
694
+
695
+ sc = ax.scatter(
696
+ embeddings_2d[mask, 0],
697
+ embeddings_2d[mask, 1],
698
+ color=colors[i],
699
+ label=c_name,
700
+ alpha=0.8,
701
+ s=3,
702
+ edgecolors='none'
703
+ )
704
+ legend_handles.append(sc)
705
+ legend_labels_list.append(c_name)
706
+
707
+ ax.set_title('UMAP', pad=3)
708
+ ax.set_xlabel('Component 1', labelpad=1)
709
+ ax.set_ylabel('Component 2', labelpad=1)
710
+ ax.tick_params(axis='both', which='major', pad=1, length=2)
711
+ ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.3)
712
+
713
+ if save_path:
714
+ umap_save_path = save_path.replace('.png', '_umap.png')
715
+ plt.savefig(umap_save_path, dpi=300, bbox_inches='tight')
716
+ print(f"UMAP image saved as: {umap_save_path}")
717
+ plt.close(fig)
718
+
719
+ return embeddings_2d
720
+
721
+ def plot_confusion_matrix(y_true, y_pred, class_names=None, normalize=None,
722
+ title='Confusion Matrix',
723
+ figsize=(mm_to_inches(84), mm_to_inches(70)),
724
+ cmap='Blues',
725
+ save_path=None,
726
+ fontsize=6):
727
+
728
+ cm = confusion_matrix(y_true, y_pred)
729
+ if normalize == 'true':
730
+ cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]
731
+ fmt = '.1%'
732
+ # title = title + ' (normalized by true labels)'
733
+ elif normalize == 'pred':
734
+ cm = cm.astype('float') / cm.sum(axis=0)[np.newaxis, :]
735
+ fmt = '.1%'
736
+ # title = title + ' (normalized by predicted labels)'
737
+ elif normalize == 'all':
738
+ cm = cm.astype('float') / cm.sum()
739
+ fmt = '.1%'
740
+ # title = title + ' (normalized globally)'
741
+ else:
742
+ fmt = 'd'
743
+
744
+ n_classes = cm.shape[0]
745
+ if class_names is None:
746
+ class_names = [f"C{i}" for i in range(n_classes)]
747
+ elif len(class_names) < n_classes:
748
+ class_names = list(class_names) + [f"C{i}" for i in range(len(class_names), n_classes)]
749
+
750
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
751
+ annot_size = fontsize if n_classes <= 10 else fontsize - 1
752
+ sns.heatmap(cm, annot=True, fmt=fmt, cmap=cmap,
753
+ xticklabels=class_names, yticklabels=class_names,
754
+ cbar=True, square=True,
755
+ linewidths=0.3, linecolor='white',
756
+ cbar_kws={"shrink": 0.7, "aspect": 15, "fraction": 0.05, "pad": 0.02},
757
+ annot_kws={"size": annot_size, "weight": 'normal'},
758
+ ax=ax)
759
+
760
+ ax.set_xlabel('Predicted Label', fontsize=fontsize, labelpad=4)
761
+ ax.set_ylabel('True Label', fontsize=fontsize, labelpad=4)
762
+ max_label_len = max([len(str(n)) for n in class_names])
763
+
764
+ if n_classes > 10 or max_label_len > 5:
765
+ rotation_angle = 45
766
+ ha_mode = 'right'
767
+ else:
768
+ rotation_angle = 0
769
+ ha_mode = 'center'
770
+
771
+ ax.tick_params(axis='both', which='major', labelsize=fontsize, length=2, pad=2)
772
+ plt.setp(ax.get_xticklabels(), rotation=rotation_angle, ha=ha_mode, rotation_mode="anchor")
773
+ plt.setp(ax.get_yticklabels(), rotation=0)
774
+
775
+ if save_path:
776
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
777
+ print(f"Confusion matrix saved as: {save_path}")
778
+
779
+ plt.show()
780
+ plt.close()
781
+
782
+ def plot_separation_heatmap(embeddings, labels, class_names=None,
783
+ metric='euclidean',
784
+ figsize=(mm_to_inches(size['double']), mm_to_inches(size['double']*0.9)),
785
+ title='Class Separation',
786
+ cmap='viridis',
787
+ save_path=None,
788
+ fontsize=6):
789
+
790
+ unique_labels = np.unique(labels)
791
+ n_classes = len(unique_labels)
792
+
793
+ if class_names is None:
794
+ display_names = [f"C{i}" for i in unique_labels]
795
+ else:
796
+ display_names = []
797
+ for label in unique_labels:
798
+ if label < len(class_names):
799
+ display_names.append(class_names[label])
800
+ else:
801
+ display_names.append(f"C{label}")
802
+
803
+ separation_matrix = np.zeros((n_classes, n_classes))
804
+ centroids = []
805
+ for label in unique_labels:
806
+ mask = labels == label
807
+ centroids.append(np.mean(embeddings[mask], axis=0))
808
+
809
+ centroids = np.array(centroids)
810
+ for i in range(n_classes):
811
+ for j in range(n_classes):
812
+ if metric == 'cosine':
813
+ separation_matrix[i, j] = 1 - np.dot(centroids[i], centroids[j]) / (
814
+ np.linalg.norm(centroids[i]) * np.linalg.norm(centroids[j]) + 1e-8)
815
+ else:
816
+ separation_matrix[i, j] = np.linalg.norm(centroids[i] - centroids[j])
817
+
818
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
819
+
820
+ annot_size = fontsize if n_classes <= 10 else fontsize - 1.5
821
+
822
+ fmt = '.2f' if metric == 'cosine' else '.1f'
823
+
824
+
825
+ sns.heatmap(separation_matrix, annot=True, fmt=fmt, cmap=cmap,
826
+ xticklabels=display_names, yticklabels=display_names,
827
+ cbar=True, square=True,
828
+ linewidths=0.3, linecolor='white',
829
+ cbar_kws={"shrink": 0.7, "aspect": 15, "fraction": 0.05, "pad": 0.02},
830
+ annot_kws={"size": annot_size, "weight": 'normal'},
831
+ ax=ax)
832
+
833
+ ax.set_xlabel('Class Label', fontsize=fontsize+1, labelpad=4)
834
+ ax.set_ylabel('Class Label', fontsize=fontsize+1, labelpad=4)
835
+
836
+ if title:
837
+ ax.set_title(title, fontsize=fontsize+2, pad=6, fontweight='bold')
838
+
839
+ cbar = ax.collections[0].colorbar
840
+ cbar.ax.tick_params(labelsize=fontsize-1)
841
+ cbar_label = 'Dist.' if metric == 'euclidean' else 'Cos. Dist.'
842
+ cbar.set_label(cbar_label, fontsize=fontsize, labelpad=4)
843
+
844
+ max_label_len = max([len(str(n)) for n in display_names])
845
+ if n_classes > 10 or max_label_len > 4:
846
+ rotation_angle = 45
847
+ ha_mode = 'right'
848
+ else:
849
+ rotation_angle = 0
850
+ ha_mode = 'center'
851
+
852
+ ax.tick_params(axis='both', which='major', labelsize=fontsize, length=2, pad=2)
853
+ plt.setp(ax.get_xticklabels(), rotation=rotation_angle, ha=ha_mode, rotation_mode="anchor")
854
+ plt.setp(ax.get_yticklabels(), rotation=0)
855
+ if save_path:
856
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
857
+ print(f"Separation heatmap saved as: {save_path}")
858
+
859
+ plt.show()
860
+ plt.close()
861
+
862
+
863
+ def plot_classification_metrics(y_true, y_pred, class_names=None,
864
+ title=None,
865
+ figsize=(mm_to_inches(size['double']), mm_to_inches(size['double']*0.9)),
866
+ save_path=None):
867
+ report = classification_report(y_true, y_pred, output_dict=True)
868
+ unique_labels = np.unique(y_true)
869
+ n_classes = len(unique_labels)
870
+
871
+ if class_names is None:
872
+ class_names = [f"Class {i}" for i in unique_labels]
873
+ elif len(class_names) < n_classes:
874
+ class_names = list(class_names) + [f"Class {i}" for i in range(len(class_names), n_classes)]
875
+
876
+ display_names = []
877
+ for label in unique_labels:
878
+ idx = int(label) if isinstance(label, (int, float, np.integer)) else list(unique_labels).index(label)
879
+ if idx < len(class_names):
880
+ display_names.append(class_names[idx])
881
+ else:
882
+ display_names.append(f"{label}")
883
+
884
+ metrics_data = {
885
+ 'precision': [report[str(label)]['precision'] for label in unique_labels],
886
+ 'recall': [report[str(label)]['recall'] for label in unique_labels],
887
+ 'f1-score': [report[str(label)]['f1-score'] for label in unique_labels]
888
+ }
889
+
890
+ plt.rcParams.update({
891
+ 'font.size': 6,
892
+ 'axes.labelsize': 7,
893
+ 'xtick.labelsize': 6,
894
+ 'ytick.labelsize': 6,
895
+ 'legend.fontsize': 5,
896
+ 'font.family': 'sans-serif',
897
+ 'lines.linewidth': 0.5,
898
+ 'axes.linewidth': 0.5
899
+ })
900
+
901
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
902
+
903
+ colors = ['#4e79a7', '#f28e2b', '#76b7b2']
904
+
905
+ bar_width = 0.25
906
+ x = np.arange(n_classes)
907
+
908
+ rects1 = ax.bar(x - bar_width, metrics_data['precision'], width=bar_width, color=colors[0], label='Precision', zorder=3)
909
+ rects2 = ax.bar(x, metrics_data['recall'], width=bar_width, color=colors[1], label='Recall', zorder=3)
910
+ rects3 = ax.bar(x + bar_width, metrics_data['f1-score'], width=bar_width, color=colors[2], label='F1 Score', zorder=3)
911
+
912
+ def autolabel(rects):
913
+ for rect in rects:
914
+ height = rect.get_height()
915
+ if height > 0:
916
+ ax.text(rect.get_x() + rect.get_width() / 2., height + 0.02,
917
+ f'{height:.2f}',
918
+ ha='center', va='bottom',
919
+ rotation=90,
920
+ fontsize=4.5)
921
+
922
+ autolabel(rects1)
923
+ autolabel(rects2)
924
+ autolabel(rects3)
925
+
926
+ ax.set_ylabel('Score')
927
+
928
+ if title:
929
+ ax.set_title(title, fontsize=7, pad=4)
930
+
931
+
932
+ ax.set_xticks(x)
933
+ max_len = max([len(str(n)) for n in display_names])
934
+ rot = 0 if max_len < 4 else (30 if max_len < 8 else 45)
935
+ ax.set_xticklabels(display_names, rotation=rot, ha='right' if rot > 0 else 'center')
936
+
937
+ ax.set_yticks(np.arange(0, 1.2, 0.2))
938
+ ax.set_ylim(0, 1.25)
939
+
940
+ ax.grid(True, axis='y', linestyle='--', alpha=0.5, zorder=0)
941
+ ax.spines['top'].set_visible(False)
942
+ ax.spines['right'].set_visible(False)
943
+
944
+ ax.legend(loc='upper center', bbox_to_anchor=(0.5, 1.0),
945
+ ncol=3, frameon=False, handletextpad=0.3)
946
+
947
+ avg_precision = report['macro avg']['precision']
948
+ avg_recall = report['macro avg']['recall']
949
+ avg_f1 = report['macro avg']['f1-score']
950
+
951
+ stats_text = (f"Macro Avg:\n"
952
+ f"P: {avg_precision:.2f}\n"
953
+ f"R: {avg_recall:.2f}\n"
954
+ f"F1: {avg_f1:.2f}")
955
+
956
+ ax.text(0.98, 0.95, stats_text, transform=ax.transAxes,
957
+ ha='right', va='top', fontsize=5,
958
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8, edgecolor='gray', linewidth=0.3))
959
+
960
+ if save_path:
961
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
962
+ print(f"✅ Chart saved as: {save_path}")
963
+ else:
964
+ plt.show()
965
+ plt.close()
966
+
967
+
968
+ def plot_multi_roc_curve(y_true, y_score, class_names=None, average="macro",
969
+ title=None,
970
+ figsize=(mm_to_inches(84), mm_to_inches(78)),
971
+ save_path=None,
972
+ zoom_view=True):
973
+
974
+ y_true = np.array(y_true, dtype=int)
975
+ n_classes = y_score.shape[1]
976
+
977
+ if class_names is None:
978
+ class_names = [f"Class {i}" for i in range(n_classes)]
979
+
980
+ y_true_bin = label_binarize(y_true, classes=range(n_classes))
981
+
982
+ fpr = dict()
983
+ tpr = dict()
984
+ roc_auc = dict()
985
+
986
+ for i in range(n_classes):
987
+ fpr[i], tpr[i], _ = roc_curve(y_true_bin[:, i], y_score[:, i])
988
+ roc_auc[i] = auc(fpr[i], tpr[i])
989
+
990
+ # Micro-average
991
+ fpr["micro"], tpr["micro"], _ = roc_curve(y_true_bin.ravel(), y_score.ravel())
992
+ roc_auc["micro"] = auc(fpr["micro"], tpr["micro"])
993
+
994
+ # Macro-average
995
+ all_fpr = np.unique(np.concatenate([fpr[i] for i in range(n_classes)]))
996
+ mean_tpr = np.zeros_like(all_fpr)
997
+ for i in range(n_classes):
998
+ mean_tpr += np.interp(all_fpr, fpr[i], tpr[i])
999
+ mean_tpr /= n_classes
1000
+ fpr["macro"] = all_fpr
1001
+ tpr["macro"] = mean_tpr
1002
+ roc_auc["macro"] = auc(fpr["macro"], tpr["macro"])
1003
+
1004
+ plt.rcParams.update({
1005
+ 'font.size': 6,
1006
+ 'axes.labelsize': 7,
1007
+ 'xtick.labelsize': 6,
1008
+ 'ytick.labelsize': 6,
1009
+ 'lines.linewidth': 0.6,
1010
+ 'axes.linewidth': 0.5,
1011
+ 'grid.linewidth': 0.3,
1012
+ 'font.family': 'sans-serif'
1013
+ })
1014
+
1015
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
1016
+
1017
+ cmap = plt.cm.tab20 if n_classes > 10 else plt.cm.tab10
1018
+ colors = cmap(np.linspace(0, 1, n_classes))
1019
+
1020
+ legend_handles = []
1021
+ legend_labels = []
1022
+
1023
+ ax.plot([0, 1], [0, 1], 'k--', lw=0.5, alpha=0.5, label='Random')
1024
+
1025
+ for i, color in zip(range(n_classes), colors):
1026
+ label_str = f'{class_names[i]} ({roc_auc[i]:.2f})'
1027
+ l, = ax.plot(fpr[i], tpr[i], color=color, lw=0.6, alpha=0.6, label=label_str)
1028
+ legend_handles.append(l)
1029
+ legend_labels.append(label_str)
1030
+
1031
+ l_micro, = ax.plot(fpr["micro"], tpr["micro"], color='deeppink', linestyle=':', lw=1.2,
1032
+ label=f'Micro-avg ({roc_auc["micro"]:.2f})')
1033
+ l_macro, = ax.plot(fpr["macro"], tpr["macro"], color='navy', linestyle='--', lw=1.2,
1034
+ label=f'Macro-avg ({roc_auc["macro"]:.2f})')
1035
+
1036
+ legend_handles = [l_micro, l_macro] + legend_handles
1037
+ legend_labels = [f'Micro-avg ({roc_auc["micro"]:.2f})', f'Macro-avg ({roc_auc["macro"]:.2f})'] + legend_labels
1038
+
1039
+ if zoom_view:
1040
+
1041
+ axins = ax.inset_axes([0.45, 0.12, 0.48, 0.45])
1042
+
1043
+ for i, color in zip(range(n_classes), colors):
1044
+ axins.plot(fpr[i], tpr[i], color=color, lw=0.8, alpha=0.8)
1045
+
1046
+ axins.plot(fpr["micro"], tpr["micro"], color='deeppink', linestyle=':', lw=1.2)
1047
+ axins.plot(fpr["macro"], tpr["macro"], color='navy', linestyle='--', lw=1.2)
1048
+
1049
+ x1, x2, y1, y2 = 0.0, 0.1, 0.9, 1.01
1050
+ axins.set_xlim(x1, x2)
1051
+ axins.set_ylim(y1, y2)
1052
+
1053
+
1054
+ axins.set_xticklabels([])
1055
+ axins.set_yticklabels([])
1056
+ axins.tick_params(axis='both', which='both', length=2)
1057
+ axins.grid(True, linestyle='--', alpha=0.3)
1058
+
1059
+ mark_inset(ax, axins, loc1=2, loc2=4, fc="none", ec="0.4", lw=0.5, linestyle='--')
1060
+
1061
+ # --- 5. Axis labels and settings ---
1062
+ ax.set_xlabel('False Positive Rate (FPR)', labelpad=2)
1063
+ ax.set_ylabel('True Positive Rate (TPR)', labelpad=2)
1064
+ if title:
1065
+ ax.set_title(title, fontsize=7, pad=4)
1066
+
1067
+ ax.grid(True, linestyle='--', alpha=0.4)
1068
+ ax.set_xlim([0.0, 1.0])
1069
+ ax.set_ylim([0.0, 1.02])
1070
+
1071
+ if n_classes <= 5:
1072
+ ax.legend(loc='lower right', fontsize=5, frameon=False)
1073
+ if zoom_view:
1074
+ ax.legend(loc='center right', fontsize=5, frameon=False, bbox_to_anchor=(1, 0.5))
1075
+ else:
1076
+ pass
1077
+
1078
+ if save_path:
1079
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
1080
+ print(f"ROC curve saved: {save_path}")
1081
+
1082
+ plt.close(fig)
1083
+
1084
+ if save_path and n_classes > 5:
1085
+ dir_name, file_name = os.path.split(save_path)
1086
+ name_root, ext = os.path.splitext(file_name)
1087
+ legend_save_path = os.path.join(dir_name, f"{name_root}_legend{ext}")
1088
+
1089
+ fig_leg = plt.figure(figsize=(3, 3))
1090
+ ax_leg = fig_leg.add_subplot(111)
1091
+ ax_leg.axis('off')
1092
+
1093
+ n_items = len(legend_labels)
1094
+ n_cols = 3 if n_items > 9 else 2
1095
+
1096
+ ax_leg.legend(
1097
+ legend_handles,
1098
+ legend_labels,
1099
+ loc='center',
1100
+ ncol=n_cols,
1101
+ frameon=False,
1102
+ fontsize=7,
1103
+ handlelength=1.5,
1104
+ columnspacing=1.0
1105
+ )
1106
+
1107
+ fig_leg.savefig(legend_save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
1108
+ print(f"ROC Legend saved separately: {legend_save_path}")
1109
+ plt.close(fig_leg)
1110
+
1111
+
1112
+ def plot_training_history(train_losses, val_losses, train_accuracies, val_accuracies,
1113
+ figsize=(mm_to_inches(84), mm_to_inches(60)),
1114
+ save_path=None):
1115
+
1116
+ plt.rcParams.update({
1117
+ 'font.size': 6,
1118
+ 'axes.labelsize': 7,
1119
+ 'xtick.labelsize': 6,
1120
+ 'ytick.labelsize': 6,
1121
+ 'legend.fontsize': 5,
1122
+ 'lines.linewidth': 0.8,
1123
+ 'axes.linewidth': 0.5,
1124
+ 'grid.linewidth': 0.3,
1125
+ 'font.family': 'sans-serif'
1126
+ })
1127
+ fig, ax1 = plt.subplots(figsize=figsize, constrained_layout=True)
1128
+
1129
+ if len(train_losses) == 0 and len(val_losses) == 0 and len(train_accuracies) == 0 and len(val_accuracies) == 0:
1130
+ print("⚠️ No training history to plot.")
1131
+ plt.close(fig)
1132
+ return
1133
+
1134
+ color_loss = 'tab:blue'
1135
+ ax1.set_xlabel('Epoch', labelpad=2)
1136
+ ax1.set_ylabel('Loss', color=color_loss, labelpad=2)
1137
+
1138
+ lines = []
1139
+
1140
+ if len(train_losses) > 0:
1141
+ epochs_train_loss = range(1, len(train_losses) + 1)
1142
+ l1, = ax1.plot(epochs_train_loss, train_losses, color=color_loss, linestyle='-', alpha=0.8, label='Train Loss')
1143
+ lines.append(l1)
1144
+ if len(val_losses) > 0:
1145
+ epochs_val_loss = range(1, len(val_losses) + 1)
1146
+ l2, = ax1.plot(epochs_val_loss, val_losses, color=color_loss, linestyle='--', alpha=0.6, label='Val Loss')
1147
+ lines.append(l2)
1148
+
1149
+ ax1.tick_params(axis='y', labelcolor=color_loss, pad=1, length=2)
1150
+ ax1.tick_params(axis='x', pad=1, length=2)
1151
+ ax1.grid(True, linestyle='--', alpha=0.3)
1152
+
1153
+ ax2 = ax1.twinx()
1154
+ color_acc = 'tab:red'
1155
+ ax2.set_ylabel('Accuracy', color=color_acc, labelpad=2)
1156
+
1157
+ if len(train_accuracies) > 0:
1158
+ epochs_train_acc = range(1, len(train_accuracies) + 1)
1159
+ l3, = ax2.plot(epochs_train_acc, train_accuracies, color=color_acc, linestyle='-', alpha=0.8, label='Train Acc')
1160
+ lines.append(l3)
1161
+ if len(val_accuracies) > 0:
1162
+ epochs_val_acc = range(1, len(val_accuracies) + 1)
1163
+ l4, = ax2.plot(epochs_val_acc, val_accuracies, color=color_acc, linestyle='--', alpha=0.6, label='Val Acc')
1164
+ lines.append(l4)
1165
+
1166
+ ax2.tick_params(axis='y', labelcolor=color_acc, pad=1, length=2)
1167
+ ax2.set_ylim([0, 1.05])
1168
+
1169
+ if lines:
1170
+ labels = [l.get_label() for l in lines]
1171
+ ax1.legend(lines, labels, loc='center right', frameon=False)
1172
+
1173
+ len_set = {len(train_losses), len(val_losses), len(train_accuracies), len(val_accuracies)}
1174
+ len_set.discard(0)
1175
+ if len(len_set) > 1:
1176
+ print(
1177
+ "⚠️ History length mismatch detected: "
1178
+ f"train_losses={len(train_losses)}, val_losses={len(val_losses)}, "
1179
+ f"train_accuracies={len(train_accuracies)}, val_accuracies={len(val_accuracies)}. "
1180
+ "Plotted each curve with its own epoch range."
1181
+ )
1182
+
1183
+ if save_path:
1184
+ plt.savefig(save_path, dpi=300, bbox_inches='tight', pad_inches=0.02)
1185
+ print(f"Training history saved as: {save_path}")
1186
+
1187
+ # plt.show()
1188
+ plt.close()
1189
+
1190
+
1191
+ def visualize_model_performance(classifier, test_loader, device, class_names=None,
1192
+ save_dir=None):
1193
+
1194
+ if save_dir and not os.path.exists(save_dir):
1195
+ os.makedirs(save_dir)
1196
+ print(f"Created directory: {save_dir}")
1197
+ all_embeddings = []
1198
+ all_labels = []
1199
+ all_preds = []
1200
+ all_probs = []
1201
+ classifier.eval()
1202
+ with torch.no_grad():
1203
+ for batch in test_loader:
1204
+ if len(batch) == 3:
1205
+ inputs, _, labels = batch
1206
+ else:
1207
+ inputs, labels = batch
1208
+ inputs = inputs.to(device)
1209
+ labels = labels.to(device)
1210
+ logits, embeddings = classifier(inputs)
1211
+ probs = torch.softmax(logits, dim=1)
1212
+ preds = torch.argmax(logits, dim=1)
1213
+ all_embeddings.append(embeddings.cpu().numpy())
1214
+ all_labels.append(labels.cpu().numpy())
1215
+ all_preds.append(preds.cpu().numpy())
1216
+ all_probs.append(probs.cpu().numpy())
1217
+ all_embeddings = np.vstack(all_embeddings)
1218
+ all_labels = np.concatenate(all_labels)
1219
+ all_preds = np.concatenate(all_preds)
1220
+ all_probs = np.vstack(all_probs)
1221
+ n_classes = all_probs.shape[1]
1222
+ if class_names is None:
1223
+ class_names = [f"class{i}" for i in range(n_classes)]
1224
+
1225
+ # 1. t-SNE
1226
+ tsne_path = os.path.join(save_dir, "tsne_visualization.png") if save_dir else None
1227
+ embeddings_2d = plot_tsne_embeddings(all_embeddings, all_labels, class_names=class_names,
1228
+ title='t-SNE', save_path=tsne_path)
1229
+ # Optional: UMAP visualization
1230
+ # umap_path = os.path.join(save_dir, "umap_visualization.png") if save_dir else None
1231
+ # plot_umap_embeddings(all_embeddings, all_labels, class_names=class_names,
1232
+ # save_path=umap_path)
1233
+
1234
+ # 2. confusion matrix
1235
+ # cm_path = os.path.join(save_dir, "confusion_matrix.png") if save_dir else None
1236
+ # plot_confusion_matrix(all_labels, all_preds, class_names=class_names,
1237
+ # title='Confusion Matrix', save_path=cm_path)
1238
+
1239
+ cm_norm_path = os.path.join(save_dir, "confusion_matrix_normalized.png") if save_dir else None
1240
+ plot_confusion_matrix(all_labels, all_preds, class_names=class_names, normalize='true',
1241
+ title='Normalized Confusion Matrix', save_path=cm_norm_path)
1242
+ # 3. Class Separation Heatmap
1243
+ sep_path = os.path.join(save_dir, "class_separation_heatmap.png") if save_dir else None
1244
+ plot_separation_heatmap(all_embeddings, all_labels, class_names=class_names,
1245
+ title='Class Separation Heatmap', save_path=sep_path)
1246
+ # 4. Classification Metrics Plot
1247
+ metrics_path = os.path.join(save_dir, "classification_metrics.png") if save_dir else None
1248
+ plot_classification_metrics(all_labels, all_preds, class_names=class_names,
1249
+ title='Classification Metrics by Class', save_path=metrics_path)
1250
+ # 5. ROC Curves
1251
+ roc_path = os.path.join(save_dir, "roc_curves.png") if save_dir else None
1252
+ plot_multi_roc_curve(all_labels, all_probs, class_names=class_names,
1253
+ title='Multi-class ROC Curves', save_path=roc_path)
1254
+ print("\nClassification Report:")
1255
+ print(classification_report(all_labels, all_preds, target_names=class_names))
1256
+
1257
+ # Save classification report
1258
+ if save_dir:
1259
+ report_path = os.path.join(save_dir, "classification_report.txt")
1260
+ with open(report_path, 'w') as f:
1261
+ f.write(classification_report(all_labels, all_preds, target_names=class_names))
1262
+ print(f"Classification report saved to: {report_path}")
1263
+
1264
+ return {
1265
+ 'embeddings_2d': embeddings_2d,
1266
+ 'true_labels': all_labels,
1267
+ 'pred_labels': all_preds,
1268
+ 'probabilities': all_probs,
1269
+ 'class_names': class_names
1270
+ }
main/few_shot_bacteria_finetune.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import json
3
+ import os
4
+ import random
5
+ from collections import defaultdict
6
+ from typing import Dict, List, Tuple
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import torch
11
+ from sklearn.metrics import accuracy_score, f1_score
12
+ from sklearn.preprocessing import LabelEncoder
13
+ from torch.utils.data import DataLoader
14
+
15
+ from Raman_Task import (
16
+ load_mae_model_for_classification,
17
+ stratified_split_with_minimum_samples,
18
+ train_predictor,
19
+ load_class_names,
20
+ )
21
+ from Ramandataset import RamanDataset, get_transforms
22
+ from evaluate_visualize import visualize_model_performance
23
+ from load_data import load_real_data
24
+
25
+
26
+ def mm_to_inches(mm: float) -> float:
27
+ return mm / 25.4
28
+
29
+
30
+ def set_seed(seed: int) -> None:
31
+ random.seed(seed)
32
+ np.random.seed(seed)
33
+ torch.manual_seed(seed)
34
+ if torch.cuda.is_available():
35
+ torch.cuda.manual_seed_all(seed)
36
+
37
+
38
+ def sample_fixed_per_class(
39
+ x_data: np.ndarray,
40
+ y_data: np.ndarray,
41
+ samples_per_class: int,
42
+ seed: int,
43
+ ) -> Tuple[np.ndarray, np.ndarray]:
44
+ rng = np.random.default_rng(seed)
45
+ selected_indices = []
46
+
47
+ for cls in np.unique(y_data):
48
+ cls_idx = np.where(y_data == cls)[0]
49
+ if len(cls_idx) < samples_per_class:
50
+ raise ValueError(
51
+ f"Class {cls} has only {len(cls_idx)} samples, "
52
+ f"cannot sample {samples_per_class}."
53
+ )
54
+ picked = rng.choice(cls_idx, size=samples_per_class, replace=False)
55
+ selected_indices.append(picked)
56
+
57
+ selected_indices = np.concatenate(selected_indices)
58
+ rng.shuffle(selected_indices)
59
+ return x_data[selected_indices], y_data[selected_indices]
60
+
61
+
62
+ def make_loaders(
63
+ x_data: np.ndarray,
64
+ y_data: np.ndarray,
65
+ batch_size: int,
66
+ random_state: int = 42,
67
+ use_train_augmentation: bool = False,
68
+ ) -> Tuple[DataLoader, DataLoader, DataLoader, Dict[str, int]]:
69
+ x_train, x_val, x_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
70
+ x_data,
71
+ y_data,
72
+ test_size=0.15,
73
+ val_size=0.15,
74
+ min_samples_per_class=1,
75
+ random_state=random_state,
76
+ )
77
+
78
+ train_transform = get_transforms() if use_train_augmentation else None
79
+ train_dataset = RamanDataset(x_train, None, labels=y_train, transform=train_transform, is_train=True)
80
+ val_dataset = RamanDataset(x_val, None, labels=y_val, transform=None, is_train=False)
81
+ test_dataset = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
82
+
83
+ train_loader = DataLoader(
84
+ train_dataset,
85
+ batch_size=batch_size,
86
+ shuffle=True,
87
+ drop_last=False,
88
+ num_workers=0,
89
+ pin_memory=False,
90
+ )
91
+ val_loader = DataLoader(
92
+ val_dataset,
93
+ batch_size=batch_size,
94
+ shuffle=False,
95
+ num_workers=0,
96
+ pin_memory=False,
97
+ )
98
+ test_loader = DataLoader(
99
+ test_dataset,
100
+ batch_size=batch_size,
101
+ shuffle=False,
102
+ num_workers=0,
103
+ pin_memory=False,
104
+ )
105
+
106
+ split_info = {
107
+ "train": len(train_dataset),
108
+ "val": len(val_dataset),
109
+ "test": len(test_dataset),
110
+ }
111
+ return train_loader, val_loader, test_loader, split_info
112
+
113
+
114
+ def save_summary_csv(summary_rows: List[Dict], csv_path: str) -> None:
115
+ fieldnames = [
116
+ "total_samples",
117
+ "samples_per_class",
118
+ "train_samples",
119
+ "val_samples",
120
+ "test_samples",
121
+ "test_accuracy",
122
+ "test_macro_f1",
123
+ ]
124
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
125
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
126
+ writer.writeheader()
127
+ for row in summary_rows:
128
+ writer.writerow(row)
129
+
130
+
131
+ def save_all_runs_csv(all_rows: List[Dict], csv_path: str) -> None:
132
+ fieldnames = [
133
+ "seed",
134
+ "total_samples",
135
+ "samples_per_class",
136
+ "train_samples",
137
+ "val_samples",
138
+ "test_samples",
139
+ "test_accuracy",
140
+ "test_macro_f1",
141
+ ]
142
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
143
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
144
+ writer.writeheader()
145
+ for row in all_rows:
146
+ writer.writerow(row)
147
+
148
+
149
+ def aggregate_rows_by_size(all_rows: List[Dict]) -> List[Dict]:
150
+ grouped: Dict[int, List[Dict]] = defaultdict(list)
151
+ for row in all_rows:
152
+ grouped[row["total_samples"]].append(row)
153
+
154
+ aggregated = []
155
+ for total_samples in sorted(grouped.keys()):
156
+ rows = grouped[total_samples]
157
+ accs = np.array([r["test_accuracy"] for r in rows], dtype=np.float64)
158
+ f1s = np.array([r["test_macro_f1"] for r in rows], dtype=np.float64)
159
+ aggregated.append(
160
+ {
161
+ "total_samples": total_samples,
162
+ "samples_per_class": rows[0]["samples_per_class"],
163
+ "runs": len(rows),
164
+ "train_samples_mean": float(np.mean([r["train_samples"] for r in rows])),
165
+ "val_samples_mean": float(np.mean([r["val_samples"] for r in rows])),
166
+ "test_samples_mean": float(np.mean([r["test_samples"] for r in rows])),
167
+ "test_accuracy_mean": float(accs.mean()),
168
+ "test_accuracy_std": float(accs.std(ddof=0)),
169
+ "test_macro_f1_mean": float(f1s.mean()),
170
+ "test_macro_f1_std": float(f1s.std(ddof=0)),
171
+ }
172
+ )
173
+ return aggregated
174
+
175
+
176
+ def save_mean_std_csv(agg_rows: List[Dict], csv_path: str) -> None:
177
+ fieldnames = [
178
+ "total_samples",
179
+ "samples_per_class",
180
+ "runs",
181
+ "train_samples_mean",
182
+ "val_samples_mean",
183
+ "test_samples_mean",
184
+ "test_accuracy_mean",
185
+ "test_accuracy_std",
186
+ "test_macro_f1_mean",
187
+ "test_macro_f1_std",
188
+ ]
189
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
190
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
191
+ writer.writeheader()
192
+ for row in agg_rows:
193
+ writer.writerow(row)
194
+
195
+
196
+ def plot_few_shot_summary(agg_rows: List[Dict], save_path: str) -> None:
197
+ x_total = [row["total_samples"] for row in agg_rows]
198
+ y_acc = [row["test_accuracy_mean"] for row in agg_rows]
199
+ y_acc_std = [row["test_accuracy_std"] for row in agg_rows]
200
+ y_f1 = [row["test_macro_f1_mean"] for row in agg_rows]
201
+ y_f1_std = [row["test_macro_f1_std"] for row in agg_rows]
202
+
203
+ plt.rcParams.update(
204
+ {
205
+ "font.family": "sans-serif",
206
+ "font.size": 7,
207
+ "axes.labelsize": 8,
208
+ "xtick.labelsize": 7,
209
+ "ytick.labelsize": 7,
210
+ "legend.fontsize": 6,
211
+ "axes.linewidth": 0.6,
212
+ "lines.linewidth": 1.2,
213
+ }
214
+ )
215
+
216
+ fig, ax = plt.subplots(
217
+ figsize=(mm_to_inches(84), mm_to_inches(62)),
218
+ constrained_layout=True,
219
+ )
220
+
221
+ ax.errorbar(
222
+ x_total,
223
+ y_acc,
224
+ yerr=y_acc_std,
225
+ marker="o",
226
+ capsize=2,
227
+ color="#1f77b4",
228
+ label="Test Accuracy (mean±std)",
229
+ )
230
+ ax.errorbar(
231
+ x_total,
232
+ y_f1,
233
+ yerr=y_f1_std,
234
+ marker="s",
235
+ capsize=2,
236
+ color="#d62728",
237
+ label="Test Macro-F1 (mean±std)",
238
+ )
239
+
240
+ ax.set_xscale("log")
241
+ ax.set_xlabel("Training Set Size (Total Samples)")
242
+ ax.set_ylabel("Performance")
243
+ ax.set_ylim(0.0, 1.02)
244
+ ax.grid(True, linestyle="--", alpha=0.35)
245
+ ax.legend(loc="lower right", frameon=False)
246
+
247
+ ax.set_title("Few-Shot Fine-Tuning Performance (Multi-Seed)", pad=4)
248
+
249
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
250
+ plt.close(fig)
251
+
252
+
253
+ def main() -> None:
254
+ base_seed = 2026
255
+ set_seed(base_seed)
256
+
257
+ base_path = "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/"
258
+ data_path = os.path.join(base_path, "bacteria-ID datasets/X_reference_interpolated.npy")
259
+ labels_path = os.path.join(base_path, "bacteria-ID datasets/y_reference.npy")
260
+ wavenumbers_path = os.path.join(base_path, "bacteria-ID datasets/wavenumbers_interpolated_3500.npy")
261
+
262
+ spectra, labels, _ = load_real_data(
263
+ data_path,
264
+ labels_path=labels_path,
265
+ wavenumbers_path=wavenumbers_path,
266
+ normalize=True,
267
+ )
268
+
269
+ mapping_path = os.path.join(base_path, 'bacteria-ID datasets/bacteria-ID-label_mapping.json')
270
+
271
+ real_class_names = load_class_names(mapping_path)
272
+ print(real_class_names)
273
+ label_encoder = LabelEncoder()
274
+ y_encoded = label_encoder.fit_transform(np.array(labels))
275
+ default_class_names = [str(item) for item in label_encoder.classes_]
276
+ num_classes = len(default_class_names)
277
+
278
+ if real_class_names:
279
+ class_names = real_class_names
280
+ print(f"✅ Loaded real class names: {class_names[:5]}...") # Show first 5
281
+ else:
282
+ class_names = default_class_names
283
+ print("⚠️ Using default class names from LabelEncoder")
284
+
285
+ if len(class_names) != num_classes:
286
+ print("⚠️ class_names length mismatch; fallback to LabelEncoder names")
287
+ class_names = default_class_names
288
+ samples_per_class_list = [10, 50, 100, 200, 500, 1000, 2000]
289
+ seeds = [2026, 2027, 2028]
290
+
291
+ if num_classes != 30:
292
+ print(f"Warning: expected 30 classes, found {num_classes} classes.")
293
+
294
+ para = {
295
+ "input_length": spectra.shape[1],
296
+ "embedding_dim": 512,
297
+ "num_heads": 16,
298
+ "num_layers": 12,
299
+ "patch_num": 100,
300
+ "epochs": 120,
301
+ "lr": 1e-4,
302
+ "weight_decay": 1e-3,
303
+ "patience": 20,
304
+ "batch_size": 64,
305
+ "mask_ratio": 0.5,
306
+ }
307
+
308
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
309
+
310
+ pretrained_dir = (
311
+ f"/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/"
312
+ f"{para['lr']}_{para['mask_ratio']}_{para['embedding_dim']}_{para['num_heads']}_{para['num_layers']}_{para['patch_num']}/"
313
+ )
314
+ pretrained_path = os.path.join(pretrained_dir, "Finetuned_modified/Fine_tuned.pth")
315
+
316
+ if not os.path.exists(pretrained_path):
317
+ raise FileNotFoundError(f"Pretrained checkpoint not found: {pretrained_path}")
318
+
319
+ study_dir = os.path.join(pretrained_dir, "few_shot_finetune_study")
320
+ os.makedirs(study_dir, exist_ok=True)
321
+
322
+ summary_rows: List[Dict] = []
323
+ all_runs_rows: List[Dict] = []
324
+
325
+ for samples_per_class in samples_per_class_list:
326
+ total_samples = samples_per_class * num_classes
327
+ run_name = f"total_{total_samples}_per_class_{samples_per_class}"
328
+ run_dir = os.path.join(study_dir, run_name)
329
+ os.makedirs(run_dir, exist_ok=True)
330
+
331
+ print("=" * 80)
332
+ print(f"Few-shot setting: {run_name}")
333
+
334
+ for seed in seeds:
335
+ set_seed(seed)
336
+ seed_dir = os.path.join(run_dir, f"seed_{seed}")
337
+ os.makedirs(seed_dir, exist_ok=True)
338
+ print(f" -> running seed {seed}")
339
+
340
+ if samples_per_class == 10:
341
+ current_epochs = 50
342
+ current_lr = 5e-5
343
+ current_weight_decay = 5e-3
344
+ current_patience = 8
345
+ current_freeze_encoder = True
346
+ current_label_smoothing = 0.1
347
+ else:
348
+ current_epochs = para["epochs"]
349
+ current_lr = para["lr"]
350
+ current_weight_decay = para["weight_decay"]
351
+ current_patience = para["patience"]
352
+ current_freeze_encoder = False
353
+ current_label_smoothing = 0.0
354
+
355
+ x_sub, y_sub = sample_fixed_per_class(
356
+ spectra,
357
+ y_encoded,
358
+ samples_per_class=samples_per_class,
359
+ seed=seed + samples_per_class,
360
+ )
361
+
362
+ train_loader, val_loader, test_loader, split_info = make_loaders(
363
+ x_sub,
364
+ y_sub,
365
+ batch_size=para["batch_size"],
366
+ random_state=seed,
367
+ use_train_augmentation=(samples_per_class == 10),
368
+ )
369
+
370
+ classifier, _, _, mae_model = load_mae_model_for_classification(
371
+ pretrained_path,
372
+ para["input_length"],
373
+ para["patch_num"],
374
+ para["embedding_dim"],
375
+ para["num_layers"],
376
+ para["num_heads"],
377
+ num_classes,
378
+ device,
379
+ )
380
+
381
+ trained_model, _ = train_predictor(
382
+ classifier=classifier,
383
+ mae_model=mae_model,
384
+ train_loader=train_loader,
385
+ val_loader=val_loader,
386
+ test_loader=test_loader,
387
+ device=device,
388
+ epochs=current_epochs,
389
+ lr=current_lr,
390
+ weight_decay=current_weight_decay,
391
+ patience=current_patience,
392
+ save_dir=seed_dir,
393
+ model_name="raman",
394
+ freeze_encoder=current_freeze_encoder,
395
+ label_smoothing=current_label_smoothing,
396
+ )
397
+
398
+ final_model_path = os.path.join(seed_dir, "final_model.pth")
399
+ torch.save(
400
+ {
401
+ "model_state_dict": trained_model.state_dict(),
402
+ "model_config": {
403
+ "input_length": para["input_length"],
404
+ "patch_num": para["patch_num"],
405
+ "embedding_dim": para["embedding_dim"],
406
+ "num_layers": para["num_layers"],
407
+ "num_heads": para["num_heads"],
408
+ "num_classes": num_classes,
409
+ },
410
+ },
411
+ final_model_path,
412
+ )
413
+
414
+ best_class_model_path = os.path.join(seed_dir, "raman_best_class.pth")
415
+ if os.path.exists(best_class_model_path):
416
+ checkpoint = torch.load(best_class_model_path, map_location=device)
417
+ classifier.load_state_dict(checkpoint["model_state_dict"])
418
+
419
+ results = visualize_model_performance(
420
+ classifier,
421
+ test_loader,
422
+ device,
423
+ class_names=class_names,
424
+ save_dir=seed_dir,
425
+ )
426
+
427
+ y_true = results["true_labels"]
428
+ y_pred = results["pred_labels"]
429
+
430
+ test_accuracy = float(accuracy_score(y_true, y_pred))
431
+ test_macro_f1 = float(f1_score(y_true, y_pred, average="macro", zero_division=0))
432
+
433
+ np.savez(
434
+ os.path.join(seed_dir, "test_predictions.npz"),
435
+ y_true=y_true,
436
+ y_pred=y_pred,
437
+ )
438
+
439
+ metrics = {
440
+ "seed": seed,
441
+ "total_samples": total_samples,
442
+ "samples_per_class": samples_per_class,
443
+ "train_samples": split_info["train"],
444
+ "val_samples": split_info["val"],
445
+ "test_samples": split_info["test"],
446
+ "test_accuracy": test_accuracy,
447
+ "test_macro_f1": test_macro_f1,
448
+ }
449
+
450
+ with open(os.path.join(seed_dir, "few_shot_metrics.json"), "w", encoding="utf-8") as f:
451
+ json.dump(metrics, f, indent=2)
452
+
453
+ all_runs_rows.append(metrics)
454
+
455
+ print(
456
+ f" seed {seed} done: "
457
+ f"acc={test_accuracy:.4f}, macro_f1={test_macro_f1:.4f}, "
458
+ f"split={split_info['train']}/{split_info['val']}/{split_info['test']}"
459
+ )
460
+
461
+ agg_one = aggregate_rows_by_size([
462
+ row for row in all_runs_rows if row["samples_per_class"] == samples_per_class
463
+ ])[0]
464
+ summary_rows.append(agg_one)
465
+ with open(os.path.join(run_dir, "aggregate_metrics.json"), "w", encoding="utf-8") as f:
466
+ json.dump(agg_one, f, indent=2)
467
+
468
+ print(
469
+ f"Aggregated {run_name}: "
470
+ f"acc={agg_one['test_accuracy_mean']:.4f}±{agg_one['test_accuracy_std']:.4f}, "
471
+ f"macro_f1={agg_one['test_macro_f1_mean']:.4f}±{agg_one['test_macro_f1_std']:.4f}"
472
+ )
473
+
474
+ all_runs_csv_path = os.path.join(study_dir, "few_shot_all_runs.csv")
475
+ save_all_runs_csv(all_runs_rows, all_runs_csv_path)
476
+
477
+ summary_csv_path = os.path.join(study_dir, "few_shot_summary_mean_std.csv")
478
+ save_mean_std_csv(summary_rows, summary_csv_path)
479
+
480
+ summary_plot_path = os.path.join(study_dir, "few_shot_performance_mean_std_84mm.png")
481
+ plot_few_shot_summary(summary_rows, summary_plot_path)
482
+
483
+ print("=" * 80)
484
+ print(f"Few-shot study completed. All-runs CSV: {all_runs_csv_path}")
485
+ print(f"Few-shot study completed. Mean-std CSV: {summary_csv_path}")
486
+ print(f"Paper figure saved: {summary_plot_path}")
487
+
488
+
489
+ if __name__ == "__main__":
490
+ main()
main/few_shot_cir.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.model_selection import StratifiedKFold, train_test_split
2
+ import os
3
+ # os.environ["HSA_OVERRIDE_GFX_VERSION"] = "11.0.0"
4
+ # os.environ["PYTORCH_ALLOC_CONF"] = "garbage_collection_threshold:0.8,max_split_size_mb:512"
5
+ # os.environ["ROCK_TENSOR_SPLIT_KERNEL"] = "0"
6
+ import torch
7
+ import numpy as np
8
+ from numpy import unique
9
+ from evaluate_visualize import visualize_model_performance
10
+ from Ramandataset import RamanDataset
11
+ from data_augumentation import augment_minority_classes
12
+ from torch.utils.data import DataLoader
13
+ from Raman_Task import load_mae_model_for_classification, train_predictor
14
+ from load_data import load_real_data
15
+
16
+ def run_k_fold_cross_validation(spectra, labels, wavenumbers, num_classes, para,
17
+ device, pretrained_path, base_save_dir,
18
+ k=5, random_state=42, class_names=None):
19
+ """
20
+ Run k-fold cross validation with data augmentation, MAE loading, and training.
21
+ """
22
+ print(f"\n{'='*20} Starting {k}-Fold Cross-Validation {'='*20}")
23
+ if class_names is None:
24
+ unique_labels = np.unique(labels)
25
+ class_names = [str(x) for x in unique_labels]
26
+ # Initialize KFold.
27
+ skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=random_state)
28
+
29
+ # Store results for each fold.
30
+ fold_results = []
31
+
32
+ # Ensure labels are a NumPy array.
33
+ y = np.array(labels)
34
+ # If labels are still raw strings, encode them before calling this function.
35
+
36
+
37
+ for fold, (train_val_idx, test_idx) in enumerate(skf.split(spectra, y)):
38
+ print(f"\n🔸 Fold {fold + 1}/{k}")
39
+
40
+ # 1. Split the data.
41
+ # Test set: the held-out fold used as the final evaluation set.
42
+ X_test_fold, y_test_fold = spectra[test_idx], y[test_idx]
43
+
44
+ # Remaining data: the other k-1 folds.
45
+ X_rest, y_rest = spectra[train_val_idx], y[train_val_idx]
46
+
47
+ # Split 10% from the remaining data for validation during training (early stopping).
48
+ # Stratification keeps the validation distribution consistent.
49
+ X_train_fold, X_val_fold, y_train_fold, y_val_fold = train_test_split(
50
+ X_rest, y_rest, test_size=0.10, random_state=random_state, stratify=y_rest
51
+ )
52
+
53
+ print(f" Train: {len(X_train_fold)}, Val (EarlyStopping): {len(X_val_fold)}, Test: {len(X_test_fold)}")
54
+
55
+ # 2. Data augmentation for the training set only.
56
+ # This calls the existing augment_minority_classes helper.
57
+ # The target sample count can be dynamic or fixed depending on the experiment.
58
+ # Here we expand each class toward the size of the largest class in the training split.
59
+ unique_cls, cls_counts = np.unique(y_train_fold, return_counts=True)
60
+ max_count = cls_counts.max()
61
+ target_samples = int(max_count * 1.2) # 稍微多一点,或者直接用固定值 159 (您代码中的值)
62
+ # target_samples = 159 # 如果您想保持和之前一样
63
+
64
+ print(f" Augmenting training data...")
65
+ X_train_aug, y_train_aug = augment_minority_classes(
66
+ X_train_fold, y_train_fold, min_samples=30, target_samples=target_samples
67
+ )
68
+ print(f" Training size after augmentation: {len(X_train_aug)}")
69
+
70
+ # 3. Build DataLoaders.
71
+ batch_size = 128 # 或者从 para 中读取
72
+
73
+ train_dataset = RamanDataset(X_train_aug, None, labels=y_train_aug, transform=None, is_train=True)
74
+ val_dataset = RamanDataset(X_val_fold, None, labels=y_val_fold, transform=None, is_train=True)
75
+ test_dataset = RamanDataset(X_test_fold, None, labels=y_test_fold, transform=None, is_train=True)
76
+
77
+ train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, drop_last=True)
78
+ val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
79
+ test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
80
+
81
+ # 4. Load a fresh model to avoid weight leakage.
82
+ # The model must be reinitialized for each fold.
83
+ classifier, encoder, decoder, mae_model = load_mae_model_for_classification(
84
+ pretrained_path, para['input_length'], para['patch_num'], para['embedding_dim'],
85
+ para['num_layers'], para['num_heads'], num_classes, device
86
+ )
87
+
88
+ # 5. 设置保存路径
89
+ fold_save_dir = os.path.join(base_save_dir, f"Fold_{fold+1}")
90
+ os.makedirs(fold_save_dir, exist_ok=True)
91
+
92
+ # 6. 训练
93
+ trained_model, _ = train_predictor(
94
+ classifier=classifier,
95
+ mae_model=mae_model,
96
+ train_loader=train_loader,
97
+ val_loader=val_loader,
98
+ test_loader=test_loader, # 这里传入 test_loader 仅作为占位或额外监控,不影响训练逻辑
99
+ device=device,
100
+ epochs=para['epoch'], # 使用参数中的 epoch
101
+ lr=para['lr_list'][0],
102
+ weight_decay=1e-3,
103
+ patience=15, # 可以适当减小 patience
104
+ save_dir=fold_save_dir,
105
+ model_name=f"raman_fold{fold+1}",
106
+ freeze_encoder=False # 解冻编码器
107
+ )
108
+
109
+ # 7. 评估 (载入该折的最佳模型)
110
+ best_model_path = os.path.join(fold_save_dir, f"raman_fold{fold+1}_best_class.pth")
111
+ if os.path.exists(best_model_path):
112
+ checkpoint = torch.load(best_model_path, map_location=device)
113
+ classifier.load_state_dict(checkpoint['model_state_dict'])
114
+ best_val_acc = checkpoint.get('val_acc', 0.0)
115
+ print(f" Loaded best model from epoch {checkpoint['epoch']} (Val Acc: {best_val_acc:.4f})")
116
+
117
+ # 在 Test Fold 上测试
118
+ classifier.eval()
119
+ correct = 0
120
+ total = 0
121
+ with torch.no_grad():
122
+ for inputs, _, targets in test_loader:
123
+ inputs, targets = inputs.to(device), targets.to(device)
124
+ if targets.dim() > 1: targets = targets.squeeze()
125
+ logits, _ = classifier(inputs)
126
+ _, predicted = torch.max(logits, 1)
127
+ total += targets.size(0)
128
+ correct += (predicted == targets).sum().item()
129
+
130
+ fold_acc = correct / total
131
+ print(f" ✅ Fold {fold+1} Test Accuracy: {fold_acc:.4f}")
132
+ fold_results.append(fold_acc)
133
+
134
+ # 可选:保存混淆矩阵等可视化
135
+ # visualize_model_performance(classifier, test_loader, device, save_dir=fold_save_dir)
136
+
137
+ # 8. 总结
138
+ mean_acc = np.mean(fold_results)
139
+ std_acc = np.std(fold_results)
140
+ print(f"\n{'='*20} Cross-Validation Results {'='*20}")
141
+ for i, acc in enumerate(fold_results):
142
+ print(f"Fold {i+1}: {acc:.4f}")
143
+ print(f"Average Accuracy: {mean_acc:.4f} ± {std_acc:.4f}")
144
+ print(f"{'='*60}")
145
+
146
+ # 将结果保存到文件
147
+ with open(os.path.join(base_save_dir, "cv_results.txt"), "w") as f:
148
+ f.write(f"K-Fold Cross Validation Results (k={k})\n")
149
+ for i, acc in enumerate(fold_results):
150
+ f.write(f"Fold {i+1}: {acc:.4f}\n")
151
+ f.write(f"\nMean Accuracy: {mean_acc:.4f}\n")
152
+ f.write(f"Std Deviation: {std_acc:.4f}\n")
153
+
154
+ model_dir = base_save_dir
155
+ # best_recon_model_path = pretrained_path
156
+ # best_recon_model_path = os.path.join(model_dir, "raman_best_recon.pth")
157
+
158
+ # if best_recon_model_path and os.path.exists(best_recon_model_path):
159
+ # checkpoint = torch.load(best_recon_model_path, map_location=device)
160
+ # mae_model.load_state_dict(checkpoint['model_state_dict'])
161
+ # print(f"✅ successfully loaded (Epoch {checkpoint['epoch']})")
162
+ best_class_model_path = os.path.join(model_dir, "raman_best_class.pth")
163
+ if best_class_model_path and os.path.exists(best_class_model_path):
164
+ checkpoint = torch.load(best_class_model_path, map_location=device)
165
+ classifier.load_state_dict(checkpoint['model_state_dict'])
166
+ print(f"✅ successfully loaded (Epoch {checkpoint['epoch']})")
167
+ print("\n📊 Evaluating model performance...")
168
+ results = visualize_model_performance(
169
+ classifier,
170
+ test_loader,
171
+ device,
172
+ class_names=class_names,
173
+ save_dir=base_save_dir
174
+ )
175
+
176
+ def main():
177
+ base_path = '/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/'
178
+
179
+ #pigment
180
+ data_path = os.path.join(base_path, 'rruff/classifier_0_3500_spectra.npy')
181
+ labels_path = os.path.join(base_path, 'rruff/classifier_0_3500_labels.npy')
182
+ wavenumbers_path = os.path.join(base_path, 'rruff/classifier_0_3500_wavenumbers.npy')
183
+
184
+ #microplastic
185
+ # data_path = os.path.join(base_path, 'processed_raman_data/better_microplastic_0_3500_spectra.npy')
186
+ # labels_path = os.path.join(base_path, 'processed_raman_data/better_microplastic_0_3500_labels.npy')
187
+ # wavenumbers_path = os.path.join(base_path, 'processed_raman_data/common_wavelengths_3500pts_0_3500.npy')
188
+ spectra, labels, wavenumbers = load_real_data(data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True)
189
+
190
+ print(f'wavenumbers shape:{wavenumbers.shape}, range: {wavenumbers[0]}-{wavenumbers[-1]} cm^-1')
191
+ input_length = spectra.shape[1]
192
+
193
+ num_classes = len(unique(labels))
194
+ print(f"🔬 Loaded data: {spectra.shape[0]} spectra, each of length {input_length}, number of classes: {num_classes}"
195
+ )
196
+ class_names = [str(label) for label in unique(labels)]
197
+ n_samples = spectra.shape[0]
198
+
199
+
200
+
201
+ from sklearn.preprocessing import LabelEncoder
202
+ y = np.array(labels)
203
+ le = LabelEncoder()
204
+ y_encoded = le.fit_transform(y)
205
+ para = {"input_length": spectra.shape[1],
206
+ "embedding_dim": 512,
207
+ "num_heads": 16,
208
+ "num_layers": 12,
209
+ "patch_num": 100,
210
+ "epoch": 300,
211
+ "patch_size": spectra.shape[1] // 100,
212
+ "lr_list": [1e-4],
213
+ "mask_ratio": 0.5,
214
+ "model": 'MAE',
215
+ "contrastive_weight": 0.2}
216
+
217
+ input_length = spectra.shape[1]
218
+ embedding_dim = para["embedding_dim"]
219
+ num_heads = para["num_heads"]
220
+ num_layers = para["num_layers"]
221
+ patch_num = para["patch_num"]
222
+ mask_ratio_value = para['mask_ratio']
223
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
224
+ print(f"\n💻 Using device: {device}")
225
+ pretrained_dir = f'/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/{para["lr_list"][0]}_{mask_ratio_value}_{embedding_dim}_{num_heads}_{num_layers}_{patch_num}/'
226
+ pretrained_path = os.path.join(pretrained_dir, 'Fine_tuned_baseonALL.pth')
227
+ save_dir = os.path.join(pretrained_dir, "Task2_mineral_5Fold_stage3")
228
+ os.makedirs(save_dir, exist_ok=True)
229
+
230
+
231
+ print("\n🚀 Starting 5-Fold Cross Validation...")
232
+ run_k_fold_cross_validation(
233
+ spectra=spectra,
234
+ labels=y_encoded,
235
+ wavenumbers=wavenumbers,
236
+ num_classes=num_classes,
237
+ para=para,
238
+ device=device,
239
+ pretrained_path=pretrained_path,
240
+ base_save_dir=save_dir,
241
+ k=5,
242
+ class_names=class_names
243
+ )
244
+
245
+ if __name__ == "__main__":
246
+ main()
main/finetune.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import numpy as np
4
+ # os.environ["HSA_DISABLE_CACHE"] = "1"
5
+ # os.environ["ROCBLAS_TENSILE_LIBPATH"] = ""
6
+ # os.environ["HSA_OVERRIDE_GFX_VERSION"] = "11.0.0"
7
+ # os.environ["PYTORCH_ROCM_ARCH"] = "gfx1100"
8
+ # os.environ["PYTORCH_FLASH_SDP_ENABLED"] = "0"
9
+ # os.environ["PYTORCH_MEM_EFFICIENT_SDP_ENABLED"] = "0"
10
+ # os.environ["PYTORCH_MATH_SDP_ENABLED"] = "1"
11
+ # os.environ["ROCBLAS_TENSILE_LIBPATH"] = "/opt/rocm/lib/rocblas/library"
12
+ import torch
13
+ from GEMS import MaskedAutoencoderRaman
14
+ from evaluate_visualize import load_and_visualize_mae_model
15
+ from pretext import train_mae
16
+ from Ramandataset import RamanDataset, get_transforms
17
+ from torch.utils.data import DataLoader
18
+ import torch.optim as optim
19
+ from load_data import load_real_data
20
+ import gc
21
+
22
+ torch.backends.cudnn.benchmark = False
23
+ torch.backends.cudnn.enabled = True
24
+ os.environ['PYTORCH_HIP_ALLOC_CONF'] = 'expandable_segments:True,max_split_size_mb:128'
25
+ os.environ['PYTORCH_CUDA_ALLOC_CONF'] = 'max_split_size_mb:128'
26
+
27
+ def clear_gpu_memory():
28
+ if torch.cuda.is_available():
29
+ torch.cuda.empty_cache()
30
+ torch.cuda.synchronize()
31
+ torch.cuda.ipc_collect()
32
+ gc.collect()
33
+
34
+ def main():
35
+ clear_gpu_memory()
36
+ status = 'finetune'
37
+ base_path = '/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/'
38
+ data_alter = None
39
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
40
+ data_path = os.path.join(base_path, 'rruff/test_processed_0_3500_spectra.npy')
41
+ labels_path = os.path.join(base_path, 'rruff/test_processed_0_3500_labels.npy')
42
+ wavenumbers_path = os.path.join(base_path, 'rruff/test_processed_0_3500_wavenumbers.npy')
43
+
44
+ train_transform = get_transforms()
45
+ data_alter = None
46
+ spectra, labels, wavenumbers = load_real_data(data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True)
47
+ processed_spectra = data_alter
48
+
49
+ input_length = spectra.shape[1]
50
+ n_samples = spectra.shape[0]
51
+ seed = 42
52
+ train_size = int(0.8 * n_samples)
53
+ val_size = int(0.1 * n_samples)
54
+
55
+ rng = np.random.default_rng(seed)
56
+ shuffled_indices = rng.permutation(n_samples)
57
+ train_indices = shuffled_indices[:train_size]
58
+ val_indices = shuffled_indices[train_size:train_size + val_size]
59
+ test_indices = shuffled_indices[train_size + val_size:]
60
+
61
+ Xs_train = spectra[train_indices]
62
+ Xs_val = spectra[val_indices]
63
+ Xs_test = spectra[test_indices]
64
+
65
+ train_labels = labels[train_indices]
66
+ val_labels = labels[val_indices]
67
+ test_labels = labels[test_indices]
68
+
69
+ print(f"Xs_train shape: {Xs_train.shape}")
70
+ print(f"Xs_val shape: {Xs_val.shape}")
71
+ print(f"Xs_test shape: {Xs_test.shape}")
72
+
73
+ train_dataset = RamanDataset(Xs_train, processed_spectra, labels=train_labels,
74
+ transform=train_transform, is_train=True)
75
+ train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
76
+
77
+ val_dataset = RamanDataset(Xs_val, processed_spectra, labels=val_labels, transform=train_transform, is_train=False)
78
+ val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)
79
+
80
+ test_dataset = RamanDataset(Xs_test, processed_spectra, labels=test_labels, transform=train_transform, is_train=True)
81
+ test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False)
82
+
83
+ del Xs_train, Xs_val, Xs_test
84
+ gc.collect()
85
+
86
+ para = {"input_length": spectra.shape[1],
87
+ "embedding_dim": 512,
88
+ "num_heads": 16,
89
+ "num_layers": 12,
90
+ "patch_num": 100,
91
+ "epoch": 300,
92
+ "feature_dim": spectra.shape[1] // 100,
93
+ "lr_list": [1e-4],
94
+ "mask_ratio": 0.5,
95
+ "model": 'MAE',
96
+ "contrastive_weight": 0.5}
97
+
98
+ input_length = spectra.shape[1]
99
+ embedding_dim = para["embedding_dim"]
100
+ num_heads = para["num_heads"]
101
+ num_layers = para["num_layers"]
102
+ patch_num = para["patch_num"]
103
+ mask_ratio_value = para['mask_ratio']
104
+ lr_list = para["lr_list"]
105
+ lr = lr_list[0]
106
+
107
+ pretrained_dir = f'/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/{para["lr_list"][0]}_{mask_ratio_value}_{embedding_dim}_{num_heads}_{num_layers}_{patch_num}/'
108
+ pretrained_path = os.path.join(pretrained_dir, 'Pretexted_baseonQM.pth')
109
+ save_dir = os.path.join(pretrained_dir, f"optimization_study_contrastive_weight/{para["contrastive_weight"]}")
110
+
111
+ os.makedirs(save_dir, exist_ok=True)
112
+ save_path = save_dir
113
+ model = MaskedAutoencoderRaman(
114
+ input_length=input_length,
115
+ patch_num=para["patch_num"],
116
+ embed_dim=para["embedding_dim"],
117
+ depth=para["num_layers"],
118
+ num_heads=para["num_heads"],
119
+ decoder_embed_dim=para["embedding_dim"] // 2,
120
+ decoder_depth=4,
121
+ decoder_num_heads=para['num_heads'] // 2
122
+ ).to(device)
123
+
124
+ total_params = sum(p.numel() for p in model.parameters())
125
+ trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
126
+ print(f"model parameters:")
127
+ print(f" - total parameters: {total_params:,}")
128
+ print(f" - trainable parameters: {trainable_params:,}")
129
+ print(f" - model size: {total_params * 4 / 1024**2:.2f} MB")
130
+
131
+ if os.path.exists(pretrained_path):
132
+ print(f"Loading pretrained model: {pretrained_path}")
133
+ checkpoint = torch.load(pretrained_path, map_location=device)
134
+
135
+ if 'model_state_dict' in checkpoint:
136
+ model.load_state_dict(checkpoint['model_state_dict'])
137
+ print("✅ Successfully loaded pretrained model weights")
138
+ elif 'state_dict' in checkpoint:
139
+ model.load_state_dict(checkpoint['state_dict'])
140
+ print("✅ Successfully loaded pretrained model weights")
141
+ else:
142
+ model.load_state_dict(checkpoint)
143
+ print("✅ Successfully loaded pretrained model weights")
144
+
145
+ print(f"Pretrained model loaded, starting fine-tuning...")
146
+ else:
147
+ print(f"⚠️ Pretrained model not found: {pretrained_path}")
148
+ print(f"Starting training from scratch...")
149
+ status = 'pretrain'
150
+
151
+ optimizer = optim.AdamW(model.parameters(), lr=para["lr_list"][0])
152
+
153
+ print(f"Start {'fine-tuning' if status == 'fine_tune' else 'pre-training'} MAE model...")
154
+ print(f"Device: {device}")
155
+ print(f"Input length: {input_length}")
156
+ print(f"Sequence length: {patch_num}")
157
+ print(f"Mask ratio: {mask_ratio_value}")
158
+ print(f"Save directory: {save_dir}")
159
+
160
+ # model, _ = train_mae(
161
+ # save_dir,
162
+ # status,
163
+ # model,
164
+ # train_loader,
165
+ # val_loader,
166
+ # test_loader,
167
+ # optimizer,
168
+ # device,
169
+ # mask_ratio=mask_ratio_value,
170
+ # epochs=para['epoch'],
171
+ # lr=optimizer.param_groups[0]['lr'],
172
+ # embedding_dim=embedding_dim,
173
+ # num_heads=num_heads,
174
+ # num_layers=num_layers,
175
+ # patch_num=patch_num,
176
+ # contrastive_weight=para["contrastive_weight"]
177
+ # )
178
+
179
+ print("Loading and visualizing model...")
180
+ model_path_for_viz = os.path.join(save_path, 'Fine_tuned.pth')
181
+ load_and_visualize_mae_model(
182
+ model_path_for_viz,
183
+ status,
184
+ test_dataset,
185
+ device,
186
+ save_dir,
187
+ input_length,
188
+ wavenumbers,
189
+ patch_num=para['patch_num'],
190
+ embedding_dim=para['embedding_dim'],
191
+ num_heads=para['num_heads'],
192
+ num_layers=para['num_layers']
193
+ )
194
+
195
+ print("Visualizing reconstruction results...")
196
+
197
+ if status == 'finetune':
198
+ print("Fine-tuning completed!")
199
+ print(f"Fine-tuned model saved at: {save_path}")
200
+ elif status == 'pretrain':
201
+ print("Pre-training completed!")
202
+ print(f"Pretrained model saved at: {pretrained_path}")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
main/hyperpara_optim.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import numpy as np
4
+ import pandas as pd
5
+ import matplotlib
6
+ matplotlib.use('Agg')
7
+ import matplotlib.pyplot as plt
8
+ import seaborn as sns
9
+ from torch.utils.data import DataLoader
10
+ import random
11
+ import optuna
12
+ import gc # [新增] 用于垃圾回收
13
+ import warnings # [新增] 用于屏蔽警告
14
+
15
+ from Ramandataset import RamanDataset
16
+ # 导入您提供的 Raman_Task 中的必要组件
17
+ from Raman_Task import (
18
+ load_real_data,
19
+ load_class_names,
20
+ stratified_split_with_minimum_samples,
21
+ augment_minority_classes,
22
+ load_mae_model_for_classification,
23
+ train_predictor,
24
+ unique
25
+ )
26
+
27
+ def mm_to_inches(mm):
28
+ return mm / 25.4
29
+
30
+ def run_hyperparameter_optimization(
31
+ num_trials=10,
32
+ epochs_per_trial=50,
33
+ resume_existing=False,
34
+ study_suffix=None,
35
+ ):
36
+ """
37
+ 执行超参数随机搜索,并生成报告和可视化图表。
38
+ """
39
+
40
+ # 1. 定义超参数搜索空间
41
+ # (这部分逻辑移动到了 objective 内部,这里仅作说明)
42
+
43
+ # 2. 数据准备 (只需加载一次原始数据,避免重复IO)
44
+ base_path = '/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/'
45
+
46
+ data_path = os.path.join(base_path, 'rruff/classifier_0_3500_spectra.npy')
47
+ labels_path = os.path.join(base_path, 'rruff/classifier_0_3500_labels.npy')
48
+ wavenumbers_path = os.path.join(base_path, 'rruff/classifier_0_3500_wavenumbers.npy')
49
+
50
+ print("Loading data for optimization...")
51
+ spectra, labels, wavenumbers = load_real_data(data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True)
52
+ num_classes = len(unique(labels))
53
+ input_length = spectra.shape[1]
54
+
55
+ # 固定数据划分,确保比较的公平性
56
+ from sklearn.preprocessing import LabelEncoder
57
+ y = np.array(labels)
58
+ le = LabelEncoder()
59
+ y_encoded = le.fit_transform(y)
60
+
61
+ X_train, X_val, X_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
62
+ spectra, y_encoded, test_size=0.15, val_size=0.15, min_samples_per_class=1, random_state=42
63
+ )
64
+
65
+ # 数据增强
66
+ X_train_augmented, y_train_augmented = augment_minority_classes(
67
+ X_train, y_train, min_samples=30, target_samples=159
68
+ )
69
+
70
+ # 3. 定义 Optuna Objective
71
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
72
+ print(f"\n🚀 Starting Hyperparameter Optimization with Optuna ({num_trials} trials)...")
73
+ print(f"Device: {device}")
74
+
75
+ # 用于存储所有试验结果以供分析
76
+ results_list = []
77
+
78
+ def objective(trial):
79
+ # [新增] 1. 每个 Trial 开始前,强制清理之前的显存碎片
80
+ gc.collect()
81
+ torch.cuda.empty_cache()
82
+
83
+ # 定义搜索空间
84
+ lr = trial.suggest_categorical('lr', [1e-3, 5e-4, 1e-4, 5e-5, 1e-5])
85
+ batch_size = trial.suggest_categorical('batch_size', [16, 32])
86
+ weight_decay = trial.suggest_categorical('weight_decay', [1e-3, 5e-4, 1e-4, 5e-5, 1e-5])
87
+ mask_ratio = trial.suggest_categorical('mask_ratio', [0.25, 0.5, 0.75])
88
+
89
+ print(f"\n--- Trial {trial.number} ---")
90
+ print(f"Params: lr={lr}, batch_size={batch_size}, weight_decay={weight_decay}, mask_ratio={mask_ratio}")
91
+
92
+ # 初始化变量,防止 finally 块报错
93
+ classifier = None
94
+ mae_model = None
95
+ train_loader = None
96
+ val_loader = None
97
+ test_loader = None
98
+
99
+ try:
100
+ # 重新构建 DataLoaders
101
+ # [注意] num_workers=0 是关键,AMD 显卡上多进程 DataLoader 容易导致 Segfault
102
+ train_dataset = RamanDataset(X_train_augmented, None, labels=y_train_augmented, transform=None, is_train=True)
103
+ train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=0)
104
+
105
+ val_dataset = RamanDataset(X_val, None, labels=y_val, transform=None, is_train=True)
106
+ val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=0)
107
+
108
+ test_dataset = RamanDataset(X_test, None, labels=y_test, transform=None, is_train=True)
109
+ test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=0)
110
+
111
+ # 加载模型
112
+ para = {"embedding_dim": 512, "num_heads": 16, "num_layers": 12, "patch_num": 100}
113
+ pretrained_path = (
114
+ f"/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/"
115
+ f"0.0001_{mask_ratio}_{para['embedding_dim']}_{para['num_heads']}_{para['num_layers']}_{para['patch_num']}/"
116
+ f"Fine_tuned_baseonALL.pth"
117
+ )
118
+
119
+ if not os.path.exists(pretrained_path):
120
+ print(f"⚠️ Trial {trial.number} pruned: pretrained model not found at {pretrained_path}")
121
+ raise optuna.exceptions.TrialPruned()
122
+
123
+ classifier, encoder, decoder, mae_model = load_mae_model_for_classification(
124
+ pretrained_path, input_length, para['patch_num'], para['embedding_dim'],
125
+ para['num_layers'], para['num_heads'], num_classes, device
126
+ )
127
+
128
+ save_dir = os.path.join(base_path, f"optimization_results/trial_{trial.number}")
129
+ os.makedirs(save_dir, exist_ok=True)
130
+
131
+ # 训练模型
132
+ # 注意:如果 Raman_Task.py 中已经实现了梯度裁剪和 .contiguous(),这里会更稳定
133
+ trained_model, _ = train_predictor(
134
+ classifier=classifier,
135
+ mae_model=mae_model,
136
+ train_loader=train_loader,
137
+ val_loader=val_loader,
138
+ test_loader=test_loader,
139
+ device=device,
140
+ epochs=epochs_per_trial,
141
+ lr=lr,
142
+ weight_decay=weight_decay,
143
+ patience=10,
144
+ save_dir=save_dir,
145
+ model_name=f"trial_{trial.number}",
146
+ freeze_encoder=False
147
+ )
148
+
149
+ # 手动计算验证集准确率
150
+ classifier.eval()
151
+ correct = 0
152
+ total = 0
153
+ with torch.no_grad():
154
+ for inputs, _, labels in val_loader:
155
+ inputs, labels = inputs.to(device), labels.to(device)
156
+ outputs, _ = classifier(inputs)
157
+ _, predicted = torch.max(outputs.data, 1)
158
+ total += labels.size(0)
159
+ correct += (predicted == labels.squeeze()).sum().item()
160
+
161
+ val_acc = correct / total
162
+
163
+ # 记录结果
164
+ result_record = {
165
+ 'trial_id': trial.number,
166
+ 'val_accuracy': val_acc,
167
+ 'lr': lr,
168
+ 'batch_size': batch_size,
169
+ 'weight_decay': weight_decay,
170
+ 'mask_ratio': mask_ratio
171
+ }
172
+ results_list.append(result_record)
173
+
174
+ # 立即保存
175
+ csv_file = "optuna_results.csv"
176
+ df_current = pd.DataFrame([result_record])
177
+ if not os.path.exists(csv_file):
178
+ df_current.to_csv(csv_file, index=False, mode='w')
179
+ else:
180
+ df_current.to_csv(csv_file, index=False, mode='a', header=False)
181
+ print(f"✅ Trial {trial.number} Finished. Acc: {val_acc:.4f}")
182
+
183
+ return val_acc
184
+
185
+ # [新增] 2. 捕获运行时错误 (OOM 或 NaN),防止整个程序崩溃
186
+ except RuntimeError as e:
187
+ error_msg = str(e)
188
+ if "out of memory" in error_msg:
189
+ print(f"⚠️ Trial {trial.number} Pruned due to CUDA OOM.")
190
+ # 抛出 TrialPruned 异常,Optuna 会标记此试验为被剪枝,而不是失败
191
+ raise optuna.exceptions.TrialPruned()
192
+ elif "nan" in error_msg.lower(): # 捕获 Loss 变成 NaN 的情况
193
+ print(f"⚠️ Trial {trial.number} Pruned due to NaN loss.")
194
+ raise optuna.exceptions.TrialPruned()
195
+ else:
196
+ print(f"❌ Trial {trial.number} Failed with RuntimeError: {e}")
197
+ return 0.0 # 或者抛出异常
198
+
199
+ except Exception as e:
200
+ print(f"❌ Trial {trial.number} Failed with unknown error: {e}")
201
+ return 0.0
202
+
203
+ finally:
204
+ # [新增] 3. 暴力清理,确保为下一个 Trial 腾出显存
205
+ if classifier is not None:
206
+ del classifier
207
+ if mae_model is not None:
208
+ del mae_model
209
+ # 清理 DataLoaders (有时它们会持有显存引用)
210
+ del train_loader, val_loader, test_loader
211
+
212
+ gc.collect()
213
+ torch.cuda.empty_cache()
214
+ print(f"🧹 Trial {trial.number} Cleanup Done.")
215
+
216
+ # 4. 创建 Study 并开始优化
217
+ if study_suffix is None:
218
+ study_suffix = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")
219
+ if resume_existing:
220
+ study_name = "raman_optimization"
221
+ else:
222
+ study_name = f"raman_optimization_fresh_{study_suffix}"
223
+
224
+ # 建议使用 SQLite 存储,这样即使程序崩溃,之前的 Trial 记录还在
225
+ storage_url = "sqlite:///{}.db".format(study_name)
226
+
227
+ study = optuna.create_study(
228
+ direction="maximize",
229
+ study_name=study_name,
230
+ storage=storage_url, # 启用持久化存储
231
+ load_if_exists=resume_existing # False 时从头开始,不加载历史结果
232
+ )
233
+
234
+ # [新增] n_jobs=1 是必须的,防止多进程导致 ROCm 崩溃
235
+ # gc_after_trial=True 让 Optuna 协助清理
236
+ study.optimize(objective, n_trials=num_trials, n_jobs=1, gc_after_trial=True)
237
+
238
+ # 5. 生成报告和可视化
239
+ print("\n" + "="*50)
240
+ print("HYPERPARAMETER OPTIMIZATION REPORT (Optuna)")
241
+ print("="*50)
242
+
243
+ if len(study.trials) > 0:
244
+ best_trial = study.best_trial
245
+ print(f"Total Trials: {len(study.trials)}")
246
+ print(f"Best Validation Accuracy: {best_trial.value:.4f}")
247
+ print("\nBest Hyperparameters:")
248
+ for key, value in best_trial.params.items():
249
+ print(f" {key}: {value}")
250
+ else:
251
+ print("No successful trials.")
252
+ print("="*50 + "\n")
253
+
254
+ all_trials_data = []
255
+ print(f"Total trials in DB: {len(study.trials)}")
256
+ for t in study.trials:
257
+ # 过滤条件:必须完成,且准确率大于 0 (排除 return 0.0 的失败情况)
258
+ if t.state == optuna.trial.TrialState.COMPLETE and t.value is not None and t.value > 0.0001:
259
+ all_trials_data.append({
260
+ 'trial_id': t.number,
261
+ 'val_accuracy': t.value,
262
+ 'lr': t.params.get('lr'),
263
+ 'batch_size': t.params.get('batch_size'),
264
+ 'weight_decay': t.params.get('weight_decay'),
265
+ 'mask_ratio': t.params.get('mask_ratio')
266
+ })
267
+
268
+ if all_trials_data:
269
+ df_results = pd.DataFrame(all_trials_data)
270
+ print(f"Visualizing {len(df_results)} total trials from database.") # 打印总数确认
271
+ df_results.to_csv("optuna_results_final.csv", index=False)
272
+ visualize_hyperparameters_full_width(df_results)
273
+ else:
274
+ print("No successful trials found in study.")
275
+
276
+ def visualize_hyperparameters_full_width(df):
277
+ """
278
+ 绘制通栏大小 (170mm 宽度) 的超参数分析图。
279
+ """
280
+ # 保持原有绘图逻辑不变
281
+ plt.rcParams.update({
282
+ 'font.size': 7,
283
+ 'axes.labelsize': 8,
284
+ 'xtick.labelsize': 7,
285
+ 'ytick.labelsize': 7,
286
+ 'legend.fontsize': 7,
287
+ 'font.family': 'sans-serif',
288
+ 'lines.linewidth': 1.0,
289
+ 'axes.linewidth': 0.8
290
+ })
291
+
292
+ fig, axes = plt.subplots(2, 2, figsize=(mm_to_inches(170), mm_to_inches(120)), constrained_layout=True)
293
+ axes = axes.flatten()
294
+
295
+ # 定义明显的中位线样式:加粗并使用深红色
296
+ median_props = dict(linewidth=1.5, color='firebrick')
297
+
298
+ # --- 子图 1: Learning Rate ---
299
+ # [修改] 添加 medianprops 以突出中位线,即使与箱体边缘重合也能看见
300
+ sns.boxplot(x='lr', y='val_accuracy', data=df, ax=axes[0], palette="Blues", linewidth=0.8, showfliers=False, medianprops=median_props)
301
+ sns.stripplot(x='lr', y='val_accuracy', data=df, ax=axes[0], color='darkblue', alpha=0.6, jitter=0.1, size=4)
302
+ axes[0].set_xlabel("Learning Rate")
303
+ axes[0].set_ylabel("Validation Accuracy")
304
+ axes[0].set_title("(a) Impact of Learning Rate")
305
+ axes[0].grid(axis='y', linestyle='--', alpha=0.5)
306
+
307
+ # --- 子图 2: Batch Size ---
308
+ sns.boxplot(x='batch_size', y='val_accuracy', data=df, ax=axes[1], palette="Greens", linewidth=0.8, showfliers=False, medianprops=median_props)
309
+ sns.stripplot(x='batch_size', y='val_accuracy', data=df, ax=axes[1], color='darkgreen', alpha=0.6, jitter=0.1, size=4)
310
+ axes[1].set_xlabel("Batch Size")
311
+ axes[1].set_ylabel("")
312
+ axes[1].set_title("(b) Impact of Batch Size")
313
+ axes[1].grid(axis='y', linestyle='--', alpha=0.5)
314
+
315
+ # --- 子图 3: Weight Decay ---
316
+ sns.boxplot(x='weight_decay', y='val_accuracy', data=df, ax=axes[2], palette="Oranges", linewidth=0.8, showfliers=False, medianprops=median_props)
317
+ sns.stripplot(x='weight_decay', y='val_accuracy', data=df, ax=axes[2], color='darkred', alpha=0.6, jitter=0.1, size=4)
318
+ axes[2].set_xlabel("Weight Decay")
319
+ axes[2].set_ylabel("")
320
+ axes[2].set_title("(c) Impact of Weight Decay")
321
+ axes[2].grid(axis='y', linestyle='--', alpha=0.5)
322
+
323
+ # --- 子图 4: Mask Ratio ---
324
+ sns.boxplot(x='mask_ratio', y='val_accuracy', data=df, ax=axes[3], palette="Purples", linewidth=0.8, showfliers=False, medianprops=median_props)
325
+ sns.stripplot(x='mask_ratio', y='val_accuracy', data=df, ax=axes[3], color='indigo', alpha=0.6, jitter=0.1, size=4)
326
+ axes[3].set_xlabel("Mask Ratio")
327
+ axes[3].set_ylabel("")
328
+ axes[3].set_title("(d) Impact of Mask Ratio")
329
+ axes[3].grid(axis='y', linestyle='--', alpha=0.5)
330
+
331
+ for ax in axes:
332
+ for spine in ax.spines.values():
333
+ spine.set_visible(True)
334
+ spine.set_edgecolor('black')
335
+ spine.set_linewidth(0.8)
336
+
337
+ save_path = "hyperparameter_optimization_analysis.png"
338
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
339
+ print(f"Visualization saved to {save_path}")
340
+
341
+ if __name__ == "__main__":
342
+ # 启用 SQLite 存储后,您可以随时中断程序,下次运行会继续之前的进度
343
+ run_hyperparameter_optimization(num_trials=25, epochs_per_trial=30, resume_existing=False)
main/hyperpara_optim_contrastive_weight.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import gc
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Dict, List, Sequence, Tuple
9
+
10
+ import matplotlib
11
+
12
+ matplotlib.use("Agg")
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import pandas as pd
16
+ import torch
17
+ from sklearn.metrics import accuracy_score, classification_report, f1_score
18
+ from sklearn.preprocessing import LabelEncoder
19
+ from torch.utils.data import DataLoader
20
+
21
+ from Ramandataset import RamanDataset, get_transforms
22
+ from Raman_Task import (
23
+ augment_all_classes_to_target,
24
+ augment_minority_classes,
25
+ load_mae_model_for_classification,
26
+ load_real_data,
27
+ stratified_split_with_minimum_samples,
28
+ train_predictor,
29
+ )
30
+
31
+
32
+ def clear_gpu_memory() -> None:
33
+ if torch.cuda.is_available():
34
+ torch.cuda.empty_cache()
35
+ torch.cuda.synchronize()
36
+ torch.cuda.ipc_collect()
37
+ gc.collect()
38
+
39
+
40
+ def mm_to_inches(mm: float) -> float:
41
+ return mm / 25.4
42
+
43
+
44
+ def build_classification_loaders(
45
+ x_train: np.ndarray,
46
+ x_val: np.ndarray,
47
+ x_test: np.ndarray,
48
+ y_train: np.ndarray,
49
+ y_val: np.ndarray,
50
+ y_test: np.ndarray,
51
+ batch_size: int,
52
+ ) -> Tuple[DataLoader, DataLoader, DataLoader]:
53
+ train_dataset = RamanDataset(x_train, None, labels=y_train, transform=None, is_train=False)
54
+ val_dataset = RamanDataset(x_val, None, labels=y_val, transform=None, is_train=False)
55
+ test_dataset = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
56
+
57
+ train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=0)
58
+ val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=0)
59
+ test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=0)
60
+ return train_loader, val_loader, test_loader
61
+
62
+
63
+ def build_classifier_split(
64
+ spectra: np.ndarray,
65
+ labels: np.ndarray,
66
+ seed: int = 42,
67
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
68
+ return stratified_split_with_minimum_samples(
69
+ spectra,
70
+ labels,
71
+ test_size=0.15,
72
+ val_size=0.15,
73
+ min_samples_per_class=1,
74
+ random_state=seed,
75
+ )
76
+
77
+
78
+ def evaluate_classifier(
79
+ classifier,
80
+ loader: DataLoader,
81
+ device: torch.device,
82
+ class_names: Sequence[str],
83
+ ) -> Tuple[float, float, np.ndarray, np.ndarray, np.ndarray, str]:
84
+ classifier.eval()
85
+ y_true: List[int] = []
86
+ y_pred: List[int] = []
87
+ y_prob: List[np.ndarray] = []
88
+
89
+ with torch.no_grad():
90
+ for inputs, _, labels in loader:
91
+ inputs = inputs.to(device)
92
+ labels = labels.to(device)
93
+ if labels.dim() > 1:
94
+ labels = labels.squeeze()
95
+ labels = labels.long()
96
+ logits, _ = classifier(inputs)
97
+ probs = torch.softmax(logits, dim=1)
98
+ preds = torch.argmax(logits, dim=1)
99
+
100
+ y_true.extend(labels.cpu().numpy().tolist())
101
+ y_pred.extend(preds.cpu().numpy().tolist())
102
+ y_prob.append(probs.cpu().numpy())
103
+
104
+ y_true_arr = np.asarray(y_true, dtype=np.int64)
105
+ y_pred_arr = np.asarray(y_pred, dtype=np.int64)
106
+ y_prob_arr = np.concatenate(y_prob, axis=0) if y_prob else np.empty((0, len(class_names)), dtype=np.float64)
107
+
108
+ acc = float(accuracy_score(y_true_arr, y_pred_arr)) if len(y_true_arr) else 0.0
109
+ macro_f1 = float(f1_score(y_true_arr, y_pred_arr, average="macro", zero_division=0)) if len(y_true_arr) else 0.0
110
+ report = classification_report(
111
+ y_true_arr,
112
+ y_pred_arr,
113
+ labels=list(range(len(class_names))),
114
+ target_names=[str(name) for name in class_names],
115
+ digits=4,
116
+ zero_division=0,
117
+ )
118
+ return acc, macro_f1, y_true_arr, y_pred_arr, y_prob_arr, report
119
+
120
+
121
+ def mm_weight_label(weight: float) -> str:
122
+ return f"{weight:.2f}".rstrip("0").rstrip(".")
123
+
124
+
125
+ def is_edge_weight(weight_label: str) -> bool:
126
+ try:
127
+ weight_value = float(weight_label)
128
+ except ValueError:
129
+ return False
130
+ return np.isclose(weight_value, 0.0) or np.isclose(weight_value, 1.0)
131
+
132
+
133
+ def plot_weight_curve(rows: List[Dict], save_path: Path) -> None:
134
+ # ensure numeric weights for plotting
135
+ weights = [float(row["contrastive_weight"]) for row in rows]
136
+ class_val = [float(row.get("classifier_val_accuracy", np.nan)) for row in rows]
137
+ class_test = [float(row.get("classifier_test_accuracy", np.nan)) for row in rows]
138
+ # support both old and new summary keys for stage-2 validation loss
139
+ pretext_loss = [float(row.get("pretext_best_val_loss", row.get("stage2_best_val_loss", np.nan))) for row in rows]
140
+
141
+ fig, ax1 = plt.subplots(figsize=(mm_to_inches(170), mm_to_inches(74)), constrained_layout=True)
142
+ ax1.plot(weights, class_val, marker="o", color="#1f77b4", label="Classifier val accuracy")
143
+ ax1.plot(weights, class_test, marker="s", color="#d62728", label="Classifier test accuracy")
144
+ ax1.set_xlabel("Contrastive weight")
145
+ ax1.set_ylabel("Accuracy")
146
+ ax1.set_ylim(0.0, 1.02)
147
+ ax1.grid(True, linestyle="--", alpha=0.3)
148
+
149
+ ax2 = ax1.twinx()
150
+ ax2.plot(weights, pretext_loss, marker="^", color="#2ca02c", label="Stage-2 best val loss")
151
+ ax2.set_ylabel("Pretext best val loss")
152
+
153
+ handles1, labels1 = ax1.get_legend_handles_labels()
154
+ handles2, labels2 = ax2.get_legend_handles_labels()
155
+ ax1.legend(handles1 + handles2, labels1 + labels2, frameon=False, loc="best")
156
+ fig.savefig(save_path, dpi=300, bbox_inches="tight", pad_inches=0.02)
157
+ plt.close(fig)
158
+
159
+
160
+ def run_contrastive_weight_hpo(
161
+ contrastive_weights: Sequence[float] | None = None,
162
+ finetune_epochs: int = 300,
163
+ classification_epochs: int = 150,
164
+ classification_lr: float = 5e-4,
165
+ classification_weight_decay: float = 5e-5,
166
+ classification_patience: int = 10,
167
+ classification_batch_size: int = 32,
168
+ classification_freeze_encoder: bool = False,
169
+ ) -> Dict:
170
+ clear_gpu_memory()
171
+ print("[HPO] loading existing contrastive checkpoints", flush=True)
172
+
173
+ base_path = "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/"
174
+ classifier_data_path = os.path.join(base_path, "rruff/classifier_0_3500_spectra.npy")
175
+ classifier_labels_path = os.path.join(base_path, "rruff/classifier_0_3500_labels.npy")
176
+ classifier_wavenumbers_path = os.path.join(base_path, "rruff/classifier_0_3500_wavenumbers.npy")
177
+
178
+ classifier_spectra, classifier_labels, classifier_wavenumbers = load_real_data(
179
+ classifier_data_path,
180
+ labels_path=classifier_labels_path,
181
+ wavenumbers_path=classifier_wavenumbers_path,
182
+ normalize=True,
183
+ )
184
+
185
+ label_encoder = LabelEncoder()
186
+ classifier_labels_encoded = label_encoder.fit_transform(classifier_labels)
187
+ class_names = [str(name) for name in label_encoder.classes_]
188
+ num_classes = len(class_names)
189
+ input_length = classifier_spectra.shape[1]
190
+
191
+ print(
192
+ f"[HPO] classifier dataset loaded: samples={len(classifier_spectra)}, classes={num_classes}, input_length={input_length}",
193
+ flush=True,
194
+ )
195
+
196
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
197
+
198
+ para = {
199
+ "embedding_dim": 512,
200
+ "num_heads": 16,
201
+ "num_layers": 12,
202
+ "patch_num": 100,
203
+ "epoch": finetune_epochs,
204
+ "lr_list": [1e-4],
205
+ "mask_ratio": 0.5,
206
+ }
207
+
208
+ pretrained_dir = (
209
+ f"/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/"
210
+ f"{para['lr_list'][0]}_{para['mask_ratio']}_{para['embedding_dim']}_{para['num_heads']}_{para['num_layers']}_{para['patch_num']}/"
211
+ )
212
+ output_root = os.path.join(pretrained_dir, "contrastive_weight_hpo")
213
+ os.makedirs(output_root, exist_ok=True)
214
+
215
+ if contrastive_weights is None:
216
+ contrastive_weights = []
217
+
218
+ weight_dirs: List[Tuple[str, str]] = []
219
+ if contrastive_weights:
220
+ for weight in contrastive_weights:
221
+ weight_label = mm_weight_label(weight)
222
+ if is_edge_weight(weight_label):
223
+ continue
224
+ trial_root = os.path.join(output_root, f"weight_{weight_label}")
225
+ weight_dirs.append((weight_label, trial_root))
226
+ else:
227
+ for item in sorted(os.listdir(output_root)):
228
+ trial_root = os.path.join(output_root, item)
229
+ if os.path.isdir(trial_root) and item.startswith("weight_"):
230
+ weight_label = item.replace("weight_", "", 1)
231
+ if is_edge_weight(weight_label):
232
+ continue
233
+ weight_dirs.append((weight_label, trial_root))
234
+
235
+ if not weight_dirs:
236
+ raise FileNotFoundError(
237
+ f"No contrastive weight directories found under {output_root}."
238
+ )
239
+
240
+ results: List[Dict] = []
241
+ best_row: Dict | None = None
242
+
243
+ seed = 42
244
+
245
+ for weight_label, trial_root in weight_dirs:
246
+ clear_gpu_memory()
247
+ classification_dir = os.path.join(trial_root, "classification")
248
+ os.makedirs(classification_dir, exist_ok=True)
249
+
250
+ print("=" * 90)
251
+ print(f"Contrastive weight trial: {weight_label}", flush=True)
252
+
253
+ finetuned_path = os.path.join(trial_root, "stage2_finetune", "Fine_tuned.pth")
254
+ if not os.path.exists(finetuned_path):
255
+ raise FileNotFoundError(
256
+ f"Missing checkpoint for weight {weight_label}: {finetuned_path}"
257
+ )
258
+
259
+ print(f" [weight={weight_label}] loading existing checkpoint: {finetuned_path}", flush=True)
260
+
261
+ stage2_checkpoint = torch.load(finetuned_path, map_location=device)
262
+ pretext_best_val_loss = float(stage2_checkpoint.get("best_val_loss", np.nan))
263
+ pretext_best_epoch = int(stage2_checkpoint.get("best_epoch", 0))
264
+
265
+ x_train_cls, x_val_cls, x_test_cls, y_train_cls, y_val_cls, y_test_cls = build_classifier_split(
266
+ classifier_spectra,
267
+ classifier_labels_encoded,
268
+ seed=seed,
269
+ )
270
+
271
+ print(
272
+ f" [stage-3] classifier split ready: train={len(x_train_cls)}, val={len(x_val_cls)}, test={len(x_test_cls)}",
273
+ flush=True,
274
+ )
275
+ unique_train, counts_train = np.unique(y_train_cls, return_counts=True)
276
+ print(f" [stage-3] train class counts before augmentation: {dict(zip(unique_train.tolist(), counts_train.tolist()))}", flush=True)
277
+
278
+ # Augment training data to 100 samples per class
279
+ x_train_cls, y_train_cls = augment_all_classes_to_target(
280
+ x_train_cls,
281
+ y_train_cls,
282
+ target_samples=100,
283
+ verbose=False,
284
+ )
285
+ unique_train_aug, counts_train_aug = np.unique(y_train_cls, return_counts=True)
286
+ print(f" [stage-3] train class counts after augmentation: {dict(zip(unique_train_aug.tolist(), counts_train_aug.tolist()))}", flush=True)
287
+
288
+ classifier, _, _, mae_model = load_mae_model_for_classification(
289
+ pretrained_path=finetuned_path,
290
+ input_length=input_length,
291
+ patch_num=para["patch_num"],
292
+ embedding_dim=para["embedding_dim"],
293
+ num_layers=para["num_layers"],
294
+ num_heads=para["num_heads"],
295
+ num_classes=num_classes,
296
+ device=device,
297
+ )
298
+
299
+ print(f" [weight={weight_label}] starting downstream classification finetune ({classification_epochs} epochs)", flush=True)
300
+ cls_train_loader, cls_val_loader, cls_test_loader = build_classification_loaders(
301
+ x_train=x_train_cls,
302
+ x_val=x_val_cls,
303
+ x_test=x_test_cls,
304
+ y_train=y_train_cls,
305
+ y_val=y_val_cls,
306
+ y_test=y_test_cls,
307
+ batch_size=classification_batch_size,
308
+ )
309
+
310
+ classifier, mae_model = train_predictor(
311
+ classifier=classifier,
312
+ mae_model=mae_model,
313
+ train_loader=cls_train_loader,
314
+ val_loader=cls_val_loader,
315
+ test_loader=cls_test_loader,
316
+ device=device,
317
+ epochs=classification_epochs,
318
+ lr=classification_lr,
319
+ weight_decay=classification_weight_decay,
320
+ patience=classification_patience,
321
+ save_dir=classification_dir,
322
+ model_name=f"contrastive_w{weight_label}",
323
+ freeze_encoder=classification_freeze_encoder,
324
+ )
325
+
326
+ classifier_val_acc, classifier_val_macro_f1, _, _, _, _ = evaluate_classifier(
327
+ classifier,
328
+ cls_val_loader,
329
+ device,
330
+ class_names,
331
+ )
332
+ classifier_test_acc, classifier_test_macro_f1, y_true_test, y_pred_test, y_prob_test, report = evaluate_classifier(
333
+ classifier,
334
+ cls_test_loader,
335
+ device,
336
+ class_names,
337
+ )
338
+
339
+ with open(os.path.join(classification_dir, "classification_report.txt"), "w", encoding="utf-8") as f:
340
+ f.write(report)
341
+ f.write(f"\nOverall Accuracy: {classifier_test_acc:.4f}\n")
342
+ f.write(f"Overall Macro-F1: {classifier_test_macro_f1:.4f}\n")
343
+
344
+ np.savez(
345
+ os.path.join(classification_dir, "test_predictions.npz"),
346
+ y_true=y_true_test,
347
+ y_pred=y_pred_test,
348
+ y_prob=y_prob_test,
349
+ )
350
+
351
+ row = {
352
+ "contrastive_weight": weight_label,
353
+ "weight_label": weight_label,
354
+ "stage2_dataset": "rruff/test_processed_0_3500",
355
+ "stage3_dataset": "rruff/classifier_0_3500",
356
+ "stage2_finetuned_path": finetuned_path,
357
+ "stage2_best_val_loss": pretext_best_val_loss,
358
+ "stage2_best_epoch": pretext_best_epoch,
359
+ "classifier_val_accuracy": classifier_val_acc,
360
+ "classifier_val_macro_f1": classifier_val_macro_f1,
361
+ "classifier_test_accuracy": classifier_test_acc,
362
+ "classifier_test_macro_f1": classifier_test_macro_f1,
363
+ "classification_freeze_encoder": classification_freeze_encoder,
364
+ "classification_lr": classification_lr,
365
+ "classification_weight_decay": classification_weight_decay,
366
+ "classification_epochs": classification_epochs,
367
+ "classification_patience": classification_patience,
368
+ }
369
+ results.append(row)
370
+
371
+ with open(os.path.join(trial_root, "trial_summary.json"), "w", encoding="utf-8") as f:
372
+ json.dump(row, f, indent=2)
373
+
374
+ if best_row is None or row["classifier_val_accuracy"] > best_row["classifier_val_accuracy"]:
375
+ best_row = row
376
+
377
+ print(
378
+ f" weight={weight_label}: pretext_val_loss={pretext_best_val_loss:.4f}, "
379
+ f"cls_val_acc={classifier_val_acc:.4f}, cls_test_acc={classifier_test_acc:.4f}"
380
+ , flush=True)
381
+
382
+ results_sorted = sorted(results, key=lambda item: item["contrastive_weight"])
383
+ csv_path = os.path.join(output_root, "contrastive_weight_hpo_results.csv")
384
+ pd.DataFrame(results_sorted).to_csv(csv_path, index=False)
385
+
386
+ summary = {
387
+ "output_root": output_root,
388
+ "weights": [row["contrastive_weight"] for row in results_sorted],
389
+ "best_row": best_row,
390
+ "results_csv": csv_path,
391
+ "stage2_dataset": "rruff/test_processed_0_3500",
392
+ "stage3_dataset": "rruff/classifier_0_3500",
393
+ "seed": seed,
394
+ "class_names": class_names,
395
+ "stage3_samples": int(len(classifier_spectra)),
396
+ "stage3_wavenumbers_min": float(classifier_wavenumbers.min()),
397
+ "stage3_wavenumbers_max": float(classifier_wavenumbers.max()),
398
+ "loaded_weight_dirs": [trial_root for _, trial_root in weight_dirs],
399
+ }
400
+
401
+ summary_path = os.path.join(output_root, "contrastive_weight_hpo_summary.json")
402
+ with open(summary_path, "w", encoding="utf-8") as f:
403
+ json.dump(summary, f, indent=2)
404
+
405
+ plot_weight_curve(results_sorted, Path(os.path.join(output_root, "contrastive_weight_hpo_curve.png")))
406
+
407
+ print("=" * 90)
408
+ print("Contrastive weight HPO completed.", flush=True)
409
+ print(f"Results CSV: {csv_path}", flush=True)
410
+ print(f"Summary JSON: {summary_path}", flush=True)
411
+ if best_row is not None:
412
+ best_weight = float(best_row["contrastive_weight"])
413
+ print(f"Best weight by validation accuracy: {best_weight:.2f}", flush=True)
414
+ print(f"Best classifier val acc: {best_row['classifier_val_accuracy']:.4f}", flush=True)
415
+ print(f"Best classifier test acc: {best_row['classifier_test_accuracy']:.4f}", flush=True)
416
+ print("=" * 90, flush=True)
417
+
418
+ return summary
419
+
420
+
421
+ if __name__ == "__main__":
422
+ run_contrastive_weight_hpo()
main/hyperpara_optim_downstream.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+ import os
3
+
4
+ import numpy as np
5
+ import optuna
6
+ import pandas as pd
7
+ import torch
8
+ from sklearn.preprocessing import LabelEncoder
9
+ from torch.utils.data import DataLoader
10
+
11
+ from Ramandataset import RamanDataset
12
+ from Raman_Task import (
13
+ augment_minority_classes,
14
+ load_mae_model_for_classification,
15
+ load_real_data,
16
+ stratified_split_with_minimum_samples,
17
+ train_predictor,
18
+ unique,
19
+ )
20
+
21
+
22
+ def evaluate_classifier_accuracy(classifier, val_loader, device):
23
+ classifier.eval()
24
+ correct = 0
25
+ total = 0
26
+ with torch.no_grad():
27
+ for inputs, _, labels in val_loader:
28
+ inputs = inputs.to(device)
29
+ labels = labels.to(device)
30
+ if labels.dim() > 1:
31
+ labels = labels.squeeze()
32
+ labels = labels.long()
33
+ logits, _ = classifier(inputs)
34
+ preds = torch.argmax(logits, dim=1)
35
+ total += labels.size(0)
36
+ correct += (preds == labels).sum().item()
37
+ return correct / max(1, total)
38
+
39
+
40
+ def run_downstream_hpo(num_trials=25, epochs_per_trial=30, pretrained_path=None):
41
+ base_path = "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/"
42
+ data_path = os.path.join(base_path, "rruff/classifier_0_3500_spectra.npy")
43
+ labels_path = os.path.join(base_path, "rruff/classifier_0_3500_labels.npy")
44
+ wavenumbers_path = os.path.join(base_path, "rruff/classifier_0_3500_wavenumbers.npy")
45
+
46
+ spectra, labels, _ = load_real_data(
47
+ data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True
48
+ )
49
+ input_length = spectra.shape[1]
50
+ num_classes = len(unique(labels))
51
+
52
+ y = np.array(labels)
53
+ le = LabelEncoder()
54
+ y_encoded = le.fit_transform(y)
55
+
56
+ x_train, x_val, x_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
57
+ spectra, y_encoded, test_size=0.15, val_size=0.15, min_samples_per_class=1, random_state=42
58
+ )
59
+
60
+ x_train_aug, y_train_aug = augment_minority_classes(x_train, y_train, min_samples=30, target_samples=159)
61
+
62
+ para = {"embedding_dim": 512, "num_heads": 16, "num_layers": 12, "patch_num": 100}
63
+ if pretrained_path is None:
64
+ pretrained_path = (
65
+ "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/model/"
66
+ "0.0001_0.5_512_16_12_100/Fine_tuned_baseonALL.pth"
67
+ )
68
+
69
+ if not os.path.exists(pretrained_path):
70
+ raise FileNotFoundError(f"Pretrained model not found: {pretrained_path}")
71
+
72
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
73
+ print(f"Using device: {device}")
74
+
75
+ def objective(trial):
76
+ gc.collect()
77
+ if torch.cuda.is_available():
78
+ torch.cuda.empty_cache()
79
+
80
+ lr = trial.suggest_categorical("lr", [1e-3, 5e-4, 1e-4, 5e-5, 1e-5])
81
+ batch_size = trial.suggest_categorical("batch_size", [16, 32])
82
+ weight_decay = trial.suggest_categorical("weight_decay", [1e-3, 5e-4, 1e-4, 5e-5, 1e-5])
83
+ freeze_encoder = trial.suggest_categorical("freeze_encoder", [True, False])
84
+
85
+ trial_dir = os.path.join(base_path, "optimization_results", f"downstream_trial_{trial.number}")
86
+ os.makedirs(trial_dir, exist_ok=True)
87
+
88
+ train_ds = RamanDataset(x_train_aug, None, labels=y_train_aug, transform=None, is_train=True)
89
+ val_ds = RamanDataset(x_val, None, labels=y_val, transform=None, is_train=False)
90
+ test_ds = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
91
+
92
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=0)
93
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
94
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
95
+
96
+ classifier, _, _, mae_model = load_mae_model_for_classification(
97
+ pretrained_path,
98
+ input_length,
99
+ para["patch_num"],
100
+ para["embedding_dim"],
101
+ para["num_layers"],
102
+ para["num_heads"],
103
+ num_classes,
104
+ device,
105
+ )
106
+
107
+ train_predictor(
108
+ classifier=classifier,
109
+ mae_model=mae_model,
110
+ train_loader=train_loader,
111
+ val_loader=val_loader,
112
+ test_loader=test_loader,
113
+ device=device,
114
+ epochs=epochs_per_trial,
115
+ lr=lr,
116
+ weight_decay=weight_decay,
117
+ patience=10,
118
+ save_dir=trial_dir,
119
+ model_name=f"downstream_trial_{trial.number}",
120
+ freeze_encoder=freeze_encoder,
121
+ )
122
+
123
+ best_ckpt = os.path.join(trial_dir, f"downstream_trial_{trial.number}_best_class.pth")
124
+ if os.path.exists(best_ckpt):
125
+ ckpt = torch.load(best_ckpt, map_location=device)
126
+ classifier.load_state_dict(ckpt["model_state_dict"])
127
+
128
+ val_acc = evaluate_classifier_accuracy(classifier, val_loader, device)
129
+
130
+ row = {
131
+ "trial_id": trial.number,
132
+ "val_accuracy": val_acc,
133
+ "lr": lr,
134
+ "batch_size": batch_size,
135
+ "weight_decay": weight_decay,
136
+ "freeze_encoder": freeze_encoder,
137
+ }
138
+ pd.DataFrame([row]).to_csv(
139
+ "optuna_downstream_results.csv",
140
+ mode="a",
141
+ header=not os.path.exists("optuna_downstream_results.csv"),
142
+ index=False,
143
+ )
144
+ return val_acc
145
+
146
+ study = optuna.create_study(
147
+ direction="maximize",
148
+ study_name=f"raman_downstream_optimization_{pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')}",
149
+ storage="sqlite:///raman_optimization.db",
150
+ load_if_exists=False,
151
+ )
152
+ study.optimize(objective, n_trials=num_trials, n_jobs=1, gc_after_trial=True)
153
+
154
+ print("=" * 60)
155
+ print("DOWNSTREAM HPO SUMMARY")
156
+ print(f"Best val acc: {study.best_value:.4f}")
157
+ print(f"Best params: {study.best_params}")
158
+ print("=" * 60)
159
+
160
+ best_trial_id = study.best_trial.number
161
+ best_trial_dir = os.path.join(base_path, "optimization_results", f"downstream_trial_{best_trial_id}")
162
+ summary = {
163
+ "best_trial_id": best_trial_id,
164
+ "best_value": float(study.best_value),
165
+ "best_params": dict(study.best_params),
166
+ "best_trial_dir": best_trial_dir,
167
+ "pretrained_path": pretrained_path,
168
+ }
169
+ return summary
170
+
171
+
172
+ if __name__ == "__main__":
173
+ run_downstream_hpo(num_trials=25, epochs_per_trial=30)
main/hyperpara_optim_pipeline.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+
4
+ from hyperpara_optim_downstream import run_downstream_hpo
5
+ from hyperpara_optim_pretrain import run_pretrain_hpo
6
+
7
+
8
+ def run_two_stage_hpo(
9
+ pretrain_trials=12,
10
+ pretrain_epochs=25,
11
+ finetune_eval_epochs=20,
12
+ downstream_trials=25,
13
+ downstream_epochs=30,
14
+ ):
15
+ print("=" * 80)
16
+ print("Stage 1/2: Pretraining HPO")
17
+ print("=" * 80)
18
+ pretrain_summary = run_pretrain_hpo(
19
+ num_trials=pretrain_trials,
20
+ pretrain_epochs=pretrain_epochs,
21
+ finetune_epochs=finetune_eval_epochs,
22
+ )
23
+
24
+ best_pretext = pretrain_summary["best_pretext_checkpoint"]
25
+ if not os.path.exists(best_pretext):
26
+ raise FileNotFoundError(
27
+ f"Best pretraining checkpoint does not exist: {best_pretext}"
28
+ )
29
+
30
+ print("=" * 80)
31
+ print("Stage 2/2: Downstream HPO (using best pretraining checkpoint)")
32
+ if "pretrain_dataset" in pretrain_summary:
33
+ print(
34
+ f"Using pretraining dataset: {pretrain_summary['pretrain_dataset']} "
35
+ f"({pretrain_summary.get('pretrain_source', 'source unknown')})"
36
+ )
37
+ print("=" * 80)
38
+ downstream_summary = run_downstream_hpo(
39
+ num_trials=downstream_trials,
40
+ epochs_per_trial=downstream_epochs,
41
+ pretrained_path=best_pretext,
42
+ )
43
+
44
+ pipeline_summary = {
45
+ "pretrain": pretrain_summary,
46
+ "downstream": downstream_summary,
47
+ }
48
+
49
+ save_path = os.path.join(
50
+ "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/ms_raman",
51
+ "optuna_two_stage_summary.json",
52
+ )
53
+ with open(save_path, "w", encoding="utf-8") as f:
54
+ json.dump(pipeline_summary, f, indent=2)
55
+
56
+ print("=" * 80)
57
+ print("TWO-STAGE HPO SUMMARY")
58
+ print(f"Saved summary: {save_path}")
59
+ if "pretrain_dataset" in pretrain_summary:
60
+ print(
61
+ f"Pretraining dataset: {pretrain_summary['pretrain_dataset']} "
62
+ f"({pretrain_summary.get('pretrain_source', 'source unknown')})"
63
+ )
64
+ print(f"Best pretraining trial: {pretrain_summary['best_trial_id']} | val={pretrain_summary['best_value']:.4f}")
65
+ print(f"Best downstream trial: {downstream_summary['best_trial_id']} | val={downstream_summary['best_value']:.4f}")
66
+ print("=" * 80)
67
+
68
+ return pipeline_summary
69
+
70
+
71
+ if __name__ == "__main__":
72
+ run_two_stage_hpo(
73
+ pretrain_trials=12,
74
+ pretrain_epochs=25,
75
+ finetune_eval_epochs=20,
76
+ downstream_trials=25,
77
+ downstream_epochs=30,
78
+ )
main/hyperpara_optim_pretrain.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+ import os
3
+
4
+ import numpy as np
5
+ import optuna
6
+ import pandas as pd
7
+ import torch
8
+ from sklearn.preprocessing import LabelEncoder
9
+ from torch.utils.data import DataLoader
10
+
11
+ from GEMS import MaskedAutoencoderRaman
12
+ from Ramandataset import RamanDataset, get_transforms
13
+ from Raman_Task import (
14
+ augment_minority_classes,
15
+ load_mae_model_for_classification,
16
+ load_real_data,
17
+ stratified_split_with_minimum_samples,
18
+ train_predictor,
19
+ unique,
20
+ )
21
+ from load_data import load_spectra_from_QMe14S
22
+ from pretext import train_mae
23
+
24
+
25
+ def evaluate_classifier_accuracy(classifier, val_loader, device):
26
+ classifier.eval()
27
+ correct = 0
28
+ total = 0
29
+ with torch.no_grad():
30
+ for inputs, _, labels in val_loader:
31
+ inputs = inputs.to(device)
32
+ labels = labels.to(device)
33
+ if labels.dim() > 1:
34
+ labels = labels.squeeze()
35
+ labels = labels.long()
36
+ logits, _ = classifier(inputs)
37
+ preds = torch.argmax(logits, dim=1)
38
+ total += labels.size(0)
39
+ correct += (preds == labels).sum().item()
40
+ return correct / max(1, total)
41
+
42
+
43
+ def run_pretrain_hpo(num_trials=12, pretrain_epochs=25, finetune_epochs=20):
44
+ base_path = "/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/"
45
+ # Pretraining uses QMe14S (self-supervised, no labels)
46
+ pretrain_dataset_name = "QMe14S"
47
+ qme14s_csv = os.path.join(base_path, "unique_substances_spectra.csv")
48
+ pretrain_spectra = load_spectra_from_QMe14S(qme14s_csv)
49
+ input_length = pretrain_spectra.shape[1]
50
+
51
+ n_pre = pretrain_spectra.shape[0]
52
+ n_pre_train = int(n_pre * 0.8)
53
+ n_pre_val = int(n_pre * 0.1)
54
+ x_pre_train = pretrain_spectra[:n_pre_train]
55
+ x_pre_val = pretrain_spectra[n_pre_train:n_pre_train + n_pre_val]
56
+ x_pre_test = pretrain_spectra[n_pre_train + n_pre_val:]
57
+
58
+ # Downstream validation still uses labeled RRUFF classification data
59
+ data_path = os.path.join(base_path, "rruff/classifier_0_3500_spectra.npy")
60
+ labels_path = os.path.join(base_path, "rruff/classifier_0_3500_labels.npy")
61
+ wavenumbers_path = os.path.join(base_path, "rruff/classifier_0_3500_wavenumbers.npy")
62
+
63
+ spectra, labels, _ = load_real_data(
64
+ data_path, labels_path=labels_path, wavenumbers_path=wavenumbers_path, normalize=True
65
+ )
66
+ num_classes = len(unique(labels))
67
+
68
+ y = np.array(labels)
69
+ le = LabelEncoder()
70
+ y_encoded = le.fit_transform(y)
71
+
72
+ x_train, x_val, x_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
73
+ spectra, y_encoded, test_size=0.15, val_size=0.15, min_samples_per_class=1, random_state=42
74
+ )
75
+
76
+ x_train_aug, y_train_aug = augment_minority_classes(x_train, y_train, min_samples=30, target_samples=159)
77
+
78
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
79
+ print(f"Using device: {device}")
80
+
81
+ result_rows = []
82
+
83
+ def objective(trial):
84
+ gc.collect()
85
+ if torch.cuda.is_available():
86
+ torch.cuda.empty_cache()
87
+
88
+ mask_ratio = trial.suggest_categorical("mask_ratio", [0.25, 0.5, 0.75])
89
+ pretrain_lr = trial.suggest_categorical("pretrain_lr", [1e-4, 5e-5])
90
+ batch_size = trial.suggest_categorical("batch_size", [16, 32])
91
+ finetune_lr = trial.suggest_categorical("finetune_lr", [1e-4, 5e-5])
92
+ weight_decay = trial.suggest_categorical("weight_decay", [1e-3, 5e-4, 1e-4])
93
+
94
+ para = {"embedding_dim": 512, "num_heads": 16, "num_layers": 12, "patch_num": 100}
95
+
96
+ trial_dir = os.path.join(base_path, "optimization_results", f"pretrain_trial_{trial.number}")
97
+ os.makedirs(trial_dir, exist_ok=True)
98
+
99
+ # Pretraining loaders (self-supervised)
100
+ ssl_transform = get_transforms()
101
+ pretrain_train_ds = RamanDataset(x_pre_train, None, labels=None, transform=ssl_transform, is_train=True)
102
+ pretrain_val_ds = RamanDataset(x_pre_val, None, labels=None, transform=ssl_transform, is_train=True)
103
+ pretrain_test_ds = RamanDataset(x_pre_test, None, labels=None, transform=ssl_transform, is_train=True)
104
+
105
+ pretrain_train_loader = DataLoader(pretrain_train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
106
+ pretrain_val_loader = DataLoader(pretrain_val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
107
+ pretrain_test_loader = DataLoader(pretrain_test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
108
+
109
+ model = MaskedAutoencoderRaman(
110
+ input_length=input_length,
111
+ patch_num=para["patch_num"],
112
+ embed_dim=para["embedding_dim"],
113
+ depth=para["num_layers"],
114
+ num_heads=para["num_heads"],
115
+ decoder_embed_dim=para["embedding_dim"] // 2,
116
+ decoder_depth=4,
117
+ decoder_num_heads=para["num_heads"] // 2,
118
+ ).to(device)
119
+ optimizer = torch.optim.AdamW(model.parameters(), lr=pretrain_lr, weight_decay=1e-4)
120
+
121
+ train_mae(
122
+ save_path=trial_dir,
123
+ status="pretrain",
124
+ model=model,
125
+ train_loader=pretrain_train_loader,
126
+ val_loader=pretrain_val_loader,
127
+ test_loader=pretrain_test_loader,
128
+ optimizer=optimizer,
129
+ device=device,
130
+ mask_ratio=mask_ratio,
131
+ epochs=pretrain_epochs,
132
+ lr=pretrain_lr,
133
+ embedding_dim=para["embedding_dim"],
134
+ num_heads=para["num_heads"],
135
+ num_layers=para["num_layers"],
136
+ patch_num=para["patch_num"],
137
+ use_amp=True,
138
+ )
139
+
140
+ pretext_ckpt = os.path.join(trial_dir, "Pretexted.pth")
141
+ if not os.path.exists(pretext_ckpt):
142
+ raise optuna.exceptions.TrialPruned()
143
+
144
+ classifier, _, _, mae_model = load_mae_model_for_classification(
145
+ pretext_ckpt,
146
+ input_length,
147
+ para["patch_num"],
148
+ para["embedding_dim"],
149
+ para["num_layers"],
150
+ para["num_heads"],
151
+ num_classes,
152
+ device,
153
+ )
154
+
155
+ clf_train_ds = RamanDataset(x_train_aug, None, labels=y_train_aug, transform=None, is_train=True)
156
+ clf_val_ds = RamanDataset(x_val, None, labels=y_val, transform=None, is_train=False)
157
+ clf_test_ds = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
158
+
159
+ clf_train_loader = DataLoader(clf_train_ds, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=0)
160
+ clf_val_loader = DataLoader(clf_val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
161
+ clf_test_loader = DataLoader(clf_test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
162
+
163
+ train_predictor(
164
+ classifier=classifier,
165
+ mae_model=mae_model,
166
+ train_loader=clf_train_loader,
167
+ val_loader=clf_val_loader,
168
+ test_loader=clf_test_loader,
169
+ device=device,
170
+ epochs=finetune_epochs,
171
+ lr=finetune_lr,
172
+ weight_decay=weight_decay,
173
+ patience=8,
174
+ save_dir=trial_dir,
175
+ model_name=f"pretrain_trial_{trial.number}",
176
+ freeze_encoder=False,
177
+ )
178
+
179
+ best_ckpt = os.path.join(trial_dir, f"pretrain_trial_{trial.number}_best_class.pth")
180
+ if os.path.exists(best_ckpt):
181
+ ckpt = torch.load(best_ckpt, map_location=device)
182
+ classifier.load_state_dict(ckpt["model_state_dict"])
183
+
184
+ val_acc = evaluate_classifier_accuracy(classifier, clf_val_loader, device)
185
+
186
+ row = {
187
+ "trial_id": trial.number,
188
+ "val_accuracy": val_acc,
189
+ "mask_ratio": mask_ratio,
190
+ "pretrain_lr": pretrain_lr,
191
+ "finetune_lr": finetune_lr,
192
+ "batch_size": batch_size,
193
+ "weight_decay": weight_decay,
194
+ }
195
+ result_rows.append(row)
196
+ pd.DataFrame([row]).to_csv(
197
+ "optuna_pretrain_results.csv",
198
+ mode="a",
199
+ header=not os.path.exists("optuna_pretrain_results.csv"),
200
+ index=False,
201
+ )
202
+ return val_acc
203
+
204
+ study = optuna.create_study(
205
+ direction="maximize",
206
+ study_name=f"raman_pretrain_optimization_{pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')}",
207
+ storage="sqlite:///raman_optimization.db",
208
+ load_if_exists=False,
209
+ )
210
+ study.optimize(objective, n_trials=num_trials, n_jobs=1, gc_after_trial=True)
211
+
212
+ print("=" * 60)
213
+ print("PRETRAIN HPO SUMMARY")
214
+ print(f"Pretraining dataset: {pretrain_dataset_name}")
215
+ print(f"Pretraining source: {qme14s_csv}")
216
+ print(f"Best val acc: {study.best_value:.4f}")
217
+ print(f"Best params: {study.best_params}")
218
+ print("=" * 60)
219
+
220
+ best_trial_id = study.best_trial.number
221
+ best_trial_dir = os.path.join(base_path, "optimization_results", f"pretrain_trial_{best_trial_id}")
222
+ best_pretext_ckpt = os.path.join(best_trial_dir, "Pretexted.pth")
223
+ summary = {
224
+ "pretrain_dataset": pretrain_dataset_name,
225
+ "pretrain_source": qme14s_csv,
226
+ "best_trial_id": best_trial_id,
227
+ "best_value": float(study.best_value),
228
+ "best_params": dict(study.best_params),
229
+ "best_trial_dir": best_trial_dir,
230
+ "best_pretext_checkpoint": best_pretext_ckpt,
231
+ }
232
+ return summary
233
+
234
+
235
+ if __name__ == "__main__":
236
+ run_pretrain_hpo(num_trials=12, pretrain_epochs=25, finetune_epochs=20)
main/load_data.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ import matplotlib.pyplot as plt
4
+ import torch
5
+ from scipy.interpolate import interp1d
6
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
7
+
8
+ def load_spectra_from_QM9(data_dir):
9
+ df = pd.read_csv(data_dir)
10
+ filtered_data = df.dropna().values
11
+ print(filtered_data[0])
12
+ def normalize_data(data):
13
+ min_val = np.min(data, axis=0, keepdims=True)
14
+ max_val = np.max(data, axis=0, keepdims=True)
15
+ return (data - min_val) / (max_val - min_val)
16
+ if filtered_data.shape[1] > 3500:
17
+ filtered_data = filtered_data[:, 1:3501]
18
+ filtered_data = np.array([normalize_data(row) for row in filtered_data])
19
+ return filtered_data
20
+
21
+ def gpu_normalize_safe(data_tensor):
22
+ nan_mask = torch.isnan(data_tensor)
23
+ inf_mask = torch.isinf(data_tensor)
24
+
25
+ if torch.any(nan_mask) or torch.any(inf_mask):
26
+ data_tensor = torch.nan_to_num(data_tensor, nan=0.0, posinf=1.0, neginf=0.0)
27
+ min_vals = torch.min(data_tensor, dim=1, keepdim=True)[0]
28
+ max_vals = torch.max(data_tensor, dim=1, keepdim=True)[0]
29
+ range_vals = max_vals - min_vals
30
+ range_vals[range_vals == 0] = 1.0
31
+ normalized = (data_tensor - min_vals) / range_vals
32
+ return normalized
33
+
34
+ def load_spectra_from_QMe14S(data_dir):
35
+ df = pd.read_csv(data_dir)
36
+ spectral_columns = [col for col in df.columns if col.startswith('wavenumber_')]
37
+
38
+ if len(spectral_columns) == 0:
39
+ raise ValueError("did not find spectral data columns starting with 'wavenumber_'")
40
+
41
+ spectral_data = df[spectral_columns].copy()
42
+ spectral_data = spectral_data.apply(pd.to_numeric, errors='coerce')
43
+ spectral_data = spectral_data.dropna()
44
+
45
+ if len(spectral_data) == 0:
46
+ raise ValueError("all rows contain NaN values after conversion")
47
+ # Ensure writable contiguous memory before converting to torch tensor.
48
+ filtered_data = spectral_data.to_numpy(dtype=np.float32, copy=True)
49
+ filtered_data = np.ascontiguousarray(filtered_data)
50
+ data_tensor = torch.from_numpy(filtered_data).to(device)
51
+ print(f"data has transferred to device: {data_tensor.device}")
52
+ if data_tensor.shape[1] > 3500:
53
+ data_tensor = data_tensor[:, :3500]
54
+ data_tensor = gpu_normalize_safe(data_tensor)
55
+ filtered_data = data_tensor.cpu().numpy()
56
+ dedimed_spectrum_list = []
57
+ for i, spectrum in enumerate(filtered_data):
58
+ original_wave_min = 500
59
+ original_wave_max = 4000
60
+ original_wavenumbers = np.linspace(original_wave_min, original_wave_max, len(spectrum))
61
+ target_wave_min = 0
62
+ target_wave_max = 3500
63
+ target_wavenumbers = np.linspace(target_wave_min, target_wave_max, 3500)
64
+ interp_func = interp1d(
65
+ original_wavenumbers,
66
+ spectrum,
67
+ kind='linear',
68
+ bounds_error=False,
69
+ fill_value=0.0,
70
+ assume_sorted=True
71
+ )
72
+ interpolated_spectrum = interp_func(target_wavenumbers)
73
+ mask_low = target_wavenumbers < 500
74
+ interpolated_spectrum[mask_low] = 1e-10
75
+ interpolated_spectrum = np.nan_to_num(interpolated_spectrum, nan=1e-10)
76
+ dedimed_spectrum_list.append(interpolated_spectrum)
77
+ dedimed_spectrum_list = np.array(dedimed_spectrum_list)
78
+ return dedimed_spectrum_list
79
+
80
+ def load_real_data(data_path, labels_path=None, wavenumbers_path=None, normalize=True):
81
+ try:
82
+ spectra = np.load(data_path)
83
+ if np.isnan(spectra).any():
84
+ spectra = np.nan_to_num(spectra, nan=0.0)
85
+
86
+ if normalize:
87
+ for i in range(spectra.shape[0]):
88
+ spectrum = spectra[i]
89
+ min_val = np.min(spectrum)
90
+ max_val = np.max(spectrum)
91
+ if max_val > min_val:
92
+ spectra[i] = (spectrum - min_val) / (max_val - min_val)
93
+ labels = None
94
+ if labels_path:
95
+ try:
96
+ labels = np.load(labels_path)
97
+ if labels.shape[0] != spectra.shape[0]:
98
+ print(f"warning:label_num({labels.shape[0]})doesn't match spectra_num({spectra.shape[0]})!")
99
+ except Exception as e:
100
+ print(f"fail to load labels: {e}")
101
+ wavenumbers = None
102
+ if wavenumbers_path is not None:
103
+ try:
104
+ wavenumbers = np.load(wavenumbers_path)
105
+ if wavenumbers.shape[0] != spectra.shape[1]:
106
+ print(f"warning:wavenumber_length({wavenumbers.shape[0]})doesn't match spectra_length({spectra.shape[1]})!")
107
+ except Exception as e:
108
+ print(f"fail to load wavenumbers: {e}")
109
+
110
+ return spectra, labels, wavenumbers
111
+
112
+ except Exception as e:
113
+ print(f"fail to load spectra data: {e}")
114
+ return None, None, None
main/plot_two_stage_hpo_a4.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def _cumulative_best(values: np.ndarray) -> np.ndarray:
10
+ return np.maximum.accumulate(values)
11
+
12
+
13
+ def _safe_read_csv(path: Path) -> pd.DataFrame:
14
+ if not path.exists():
15
+ raise FileNotFoundError(f"Missing file: {path}")
16
+ return pd.read_csv(path)
17
+
18
+
19
+ def _set_pub_style() -> None:
20
+ # Serif + high DPI for direct manuscript use.
21
+ plt.rcParams.update(
22
+ {
23
+ "font.family": "DejaVu Serif",
24
+ "font.size": 10,
25
+ "axes.titlesize": 11,
26
+ "axes.labelsize": 10,
27
+ "legend.fontsize": 9,
28
+ "figure.dpi": 300,
29
+ "savefig.dpi": 300,
30
+ }
31
+ )
32
+
33
+
34
+ def plot_pretrain_a4(pre_df: pd.DataFrame, summary: dict, project_root: Path) -> None:
35
+ pre_df = pre_df.sort_values("trial_id").reset_index(drop=True)
36
+ pre_y = pre_df["val_accuracy"].to_numpy(dtype=float)
37
+ pre_best = _cumulative_best(pre_y)
38
+ x_pre = np.arange(len(pre_y))
39
+
40
+ fig = plt.figure(figsize=(8.27, 11.69), constrained_layout=True)
41
+ gs = fig.add_gridspec(3, 1, height_ratios=[1.05, 1.0, 0.9])
42
+ ax1 = fig.add_subplot(gs[0, 0])
43
+ ax2 = fig.add_subplot(gs[1, 0])
44
+ ax3 = fig.add_subplot(gs[2, 0])
45
+
46
+ ax1.plot(x_pre, pre_y, "o-", lw=1.3, ms=4, alpha=0.7, color="#1f77b4", label="Trial accuracy")
47
+ ax1.plot(x_pre, pre_best, "-", lw=2.6, color="#0d3b66", label="Cumulative best")
48
+ best_idx = int(np.argmax(pre_y))
49
+ ax1.scatter([best_idx], [pre_y[best_idx]], s=85, marker="*", color="#d62728", zorder=5, label="Best trial")
50
+ ax1.set_title("A. Pretraining HPO Trajectory")
51
+ ax1.set_xlabel("Trial index")
52
+ ax1.set_ylabel("Validation accuracy")
53
+ ax1.set_ylim(0.80, 1.0)
54
+ ax1.grid(alpha=0.25)
55
+ ax1.legend(loc="lower right", frameon=False)
56
+
57
+ if "mask_ratio" in pre_df.columns:
58
+ mask_groups = pre_df.groupby("mask_ratio")["val_accuracy"]
59
+ ratios = sorted(mask_groups.groups.keys())
60
+ means = [mask_groups.get_group(r).mean() for r in ratios]
61
+ stds = [mask_groups.get_group(r).std(ddof=1) if len(mask_groups.get_group(r)) > 1 else 0.0 for r in ratios]
62
+ ax2.bar([str(r) for r in ratios], means, yerr=stds, capsize=4, color="#4e79a7", alpha=0.9)
63
+ ax2.set_title("B. Mask Ratio Sensitivity")
64
+ ax2.set_xlabel("Mask ratio")
65
+ ax2.set_ylabel("Mean val accuracy")
66
+ ax2.set_ylim(0.80, 1.0)
67
+ ax2.grid(axis="y", alpha=0.25)
68
+
69
+ if "pretrain_lr" in pre_df.columns:
70
+ lr_groups = pre_df.groupby("pretrain_lr")["val_accuracy"]
71
+ lrs = sorted(lr_groups.groups.keys())
72
+ lr_means = [lr_groups.get_group(lr).mean() for lr in lrs]
73
+ ax3.bar([f"{lr:.0e}" for lr in lrs], lr_means, color="#59a14f", alpha=0.9)
74
+ ax3.set_title("C. Learning Rate Effect (Pretraining Stage)")
75
+ ax3.set_xlabel("Pretraining learning rate")
76
+ ax3.set_ylabel("Mean val accuracy")
77
+ ax3.set_ylim(0.80, 1.0)
78
+ ax3.grid(axis="y", alpha=0.25)
79
+
80
+ pre_summary = summary.get("pretrain", {})
81
+ dataset_name = pre_summary.get("pretrain_dataset", "QMe14S")
82
+ fig.suptitle(
83
+ f"Pretraining Hyperparameter Optimization ({dataset_name})",
84
+ fontsize=13,
85
+ fontweight="bold",
86
+ y=1.01,
87
+ )
88
+
89
+ out_png = project_root / "hpo_pretrain_a4_figure.png"
90
+ out_pdf = project_root / "hpo_pretrain_a4_figure.pdf"
91
+ fig.savefig(out_png, bbox_inches="tight")
92
+ fig.savefig(out_pdf, bbox_inches="tight")
93
+ plt.close(fig)
94
+ print(f"Saved: {out_png}")
95
+ print(f"Saved: {out_pdf}")
96
+
97
+
98
+ def plot_downstream_a4(down_df: pd.DataFrame, summary: dict, project_root: Path) -> None:
99
+ down_df = down_df.sort_values("trial_id").reset_index(drop=True)
100
+ down_y = down_df["val_accuracy"].to_numpy(dtype=float)
101
+ down_best = _cumulative_best(down_y)
102
+ x_down = np.arange(len(down_y))
103
+
104
+ fig = plt.figure(figsize=(8.27, 11.69), constrained_layout=True)
105
+ gs = fig.add_gridspec(3, 1, height_ratios=[1.05, 1.0, 0.9])
106
+ ax1 = fig.add_subplot(gs[0, 0])
107
+ ax2 = fig.add_subplot(gs[1, 0])
108
+ ax3 = fig.add_subplot(gs[2, 0])
109
+
110
+ ax1.plot(x_down, down_y, "o-", lw=1.3, ms=4, alpha=0.7, color="#d62728", label="Trial accuracy")
111
+ ax1.plot(x_down, down_best, "-", lw=2.6, color="#7f0000", label="Cumulative best")
112
+ best_idx = int(np.argmax(down_y))
113
+ ax1.scatter([best_idx], [down_y[best_idx]], s=85, marker="*", color="#1f77b4", zorder=5, label="Best trial")
114
+ ax1.set_title("A. Downstream Finetuning HPO Trajectory")
115
+ ax1.set_xlabel("Trial index")
116
+ ax1.set_ylabel("Validation accuracy")
117
+ ax1.set_ylim(0.55, 1.0)
118
+ ax1.grid(alpha=0.25)
119
+ ax1.legend(loc="lower right", frameon=False)
120
+
121
+ if "freeze_encoder" in down_df.columns:
122
+ freeze_stats = down_df.groupby("freeze_encoder")["val_accuracy"].agg(["mean", "std"]).reset_index()
123
+ freeze_stats["std"] = freeze_stats["std"].fillna(0.0)
124
+ order = [True, False]
125
+ labels = ["Frozen encoder", "Unfrozen encoder"]
126
+ means = [
127
+ float(freeze_stats.loc[freeze_stats["freeze_encoder"] == o, "mean"].iloc[0])
128
+ if (freeze_stats["freeze_encoder"] == o).any()
129
+ else np.nan
130
+ for o in order
131
+ ]
132
+ stds = [
133
+ float(freeze_stats.loc[freeze_stats["freeze_encoder"] == o, "std"].iloc[0])
134
+ if (freeze_stats["freeze_encoder"] == o).any()
135
+ else 0.0
136
+ for o in order
137
+ ]
138
+ ax2.bar(labels, means, yerr=stds, capsize=4, color=["#9c755f", "#59a14f"], alpha=0.9)
139
+ ax2.set_title("B. Encoder Strategy Effect")
140
+ ax2.set_ylabel("Mean val accuracy")
141
+ ax2.set_ylim(0.55, 1.0)
142
+ ax2.grid(axis="y", alpha=0.25)
143
+
144
+ if "lr" in down_df.columns:
145
+ lr_groups = down_df.groupby("lr")["val_accuracy"]
146
+ lrs = sorted(lr_groups.groups.keys())
147
+ lr_means = [lr_groups.get_group(lr).mean() for lr in lrs]
148
+ ax3.bar([f"{lr:.0e}" for lr in lrs], lr_means, color="#f28e2b", alpha=0.9)
149
+ ax3.set_title("C. Learning Rate Effect (Downstream Stage)")
150
+ ax3.set_xlabel("Finetuning learning rate")
151
+ ax3.set_ylabel("Mean val accuracy")
152
+ ax3.set_ylim(0.55, 1.0)
153
+ ax3.grid(axis="y", alpha=0.25)
154
+
155
+ down_summary = summary.get("downstream", {})
156
+ fig.suptitle(
157
+ "Downstream Finetuning Hyperparameter Optimization",
158
+ fontsize=13,
159
+ fontweight="bold",
160
+ y=1.01,
161
+ )
162
+
163
+ out_png = project_root / "hpo_downstream_a4_figure.png"
164
+ out_pdf = project_root / "hpo_downstream_a4_figure.pdf"
165
+ fig.savefig(out_png, bbox_inches="tight")
166
+ fig.savefig(out_pdf, bbox_inches="tight")
167
+ plt.close(fig)
168
+ print(f"Saved: {out_png}")
169
+ print(f"Saved: {out_pdf}")
170
+
171
+
172
+ def main() -> None:
173
+ project_root = Path(__file__).resolve().parents[1]
174
+ pretrain_csv = project_root / "optuna_pretrain_results.csv"
175
+ downstream_csv = project_root / "optuna_downstream_results.csv"
176
+ summary_json = project_root / "optuna_two_stage_summary.json"
177
+
178
+ pre_df = _safe_read_csv(pretrain_csv)
179
+ down_df = _safe_read_csv(downstream_csv)
180
+
181
+ if not summary_json.exists():
182
+ raise FileNotFoundError(f"Missing file: {summary_json}")
183
+ with summary_json.open("r", encoding="utf-8") as f:
184
+ summary = json.load(f)
185
+
186
+ _set_pub_style()
187
+ plot_pretrain_a4(pre_df, summary, project_root)
188
+ plot_downstream_a4(down_df, summary, project_root)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
main/pretext.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+ import torch.optim as optim
4
+ import os
5
+ from tqdm import tqdm
6
+ from evaluate_visualize import load_and_visualize_mae_model
7
+ import numpy as np
8
+ import matplotlib.pyplot as plt
9
+ from torch.utils.data import DataLoader
10
+ from Ramandataset import RamanDataset, get_transforms
11
+ import time
12
+ from load_data import load_spectra_from_QMe14S
13
+ import gc
14
+ from GEMS import MaskedAutoencoderRaman
15
+
16
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
17
+
18
+ def contrastive_loss(embeddings1, embeddings2, temperature=0.5):
19
+ embeddings1 = F.normalize(embeddings1, p=2, dim=1)
20
+ embeddings2 = F.normalize(embeddings2, p=2, dim=1)
21
+ batch_size = embeddings1.shape[0]
22
+ similarity_matrix = torch.matmul(embeddings1, embeddings2.T) / temperature
23
+ labels = torch.arange(batch_size, device=similarity_matrix.device)
24
+ loss = F.cross_entropy(similarity_matrix, labels) + F.cross_entropy(similarity_matrix.T, labels)
25
+ return loss / 2.0
26
+
27
+ def train_mae(save_path, status, model, train_loader, val_loader, test_loader,
28
+ optimizer, device, mask_ratio=0.75, epochs=50, lr=0.001,
29
+ embedding_dim=128, num_heads=8, num_layers=6, patch_num=20,
30
+ contrastive_weight=0.3, use_amp=True):
31
+
32
+ start_time = time.time()
33
+ transforms = get_transforms()
34
+ early_stop_config = {
35
+ 'patience': 15,
36
+ 'min_delta': 1e-4,
37
+ 'restore_best_weights': True,
38
+ 'monitor': 'val_loss'
39
+ }
40
+
41
+ scheduler_config = {
42
+ 'factor': 0.5,
43
+ 'patience': 5,
44
+ 'min_lr': 1e-7,
45
+ 'threshold': 1e-2
46
+ }
47
+
48
+ scaler = None
49
+ if use_amp:
50
+ try:
51
+ scaler = torch.amp.GradScaler('cuda')
52
+ except (AttributeError, TypeError):
53
+ scaler = torch.cuda.amp.GradScaler()
54
+ else:
55
+ scaler = None
56
+
57
+ def optimizer_step(loss):
58
+ if use_amp and scaler is not None:
59
+ scaler.scale(loss).backward()
60
+ scaler.unscale_(optimizer)
61
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
62
+ scaler.step(optimizer)
63
+ scaler.update()
64
+ else:
65
+ loss.backward()
66
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
67
+ optimizer.step()
68
+ if device.type == 'cuda':
69
+ torch.cuda.synchronize()
70
+
71
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
72
+ optimizer,
73
+ mode='min',
74
+ factor=scheduler_config['factor'],
75
+ patience=scheduler_config['patience'],
76
+ min_lr=scheduler_config['min_lr'],
77
+ threshold=scheduler_config['threshold']
78
+ )
79
+
80
+ best_val_loss = float('inf')
81
+ best_epoch = 0
82
+ patience_counter = 0
83
+ best_model_state = None
84
+
85
+ train_losses = []
86
+ val_losses = []
87
+ test_losses = []
88
+ learning_rates = []
89
+ detailed_losses = {
90
+ 'recon': [], 'contrast': [], 'total': []
91
+ }
92
+ for epoch in range(epochs):
93
+ epoch_base_mask = mask_ratio
94
+
95
+
96
+ model.train()
97
+ total_loss = 0
98
+ total_recon_loss = 0
99
+ total_contrast_loss = 0
100
+ batch_count = 0
101
+ temperature = 0.07
102
+
103
+ train_pbar = tqdm(train_loader, desc=f"train Epoch {epoch+1}/{epochs}")
104
+
105
+ for _, batch in enumerate(train_pbar):
106
+ current_mask_ratio = mask_ratio
107
+ if status == 'pretrain':
108
+ src, tgt, _ = batch
109
+ src = src.to(device, non_blocking=True)
110
+ tgt = tgt.to(device, non_blocking=True)
111
+ optimizer.zero_grad()
112
+ with torch.amp.autocast(device_type='cuda'):
113
+ _, embedding, _, recon_loss = model(tgt, mask_ratio=current_mask_ratio, tgt=src)
114
+ loss = recon_loss
115
+ contrast_loss = torch.tensor(0.0)
116
+ optimizer_step(recon_loss)
117
+
118
+ else:
119
+ src, tgt, labels = batch
120
+ src = src.to(device, non_blocking=True)
121
+ tgt = tgt.to(device, non_blocking=True)
122
+ labels = labels.to(device, non_blocking=True)
123
+ src_2 = transforms(src.cpu())
124
+ src2 = src_2.to(device, non_blocking=True)
125
+ optimizer.zero_grad()
126
+ _, embedding, _, recon_loss= model(tgt, mask_ratio=current_mask_ratio, tgt=src)
127
+ _, embedding2, _, _ = model(src2, mask_ratio=current_mask_ratio, tgt=src)
128
+ contrast_loss = contrastive_loss(embedding, embedding2, temperature=temperature)
129
+ loss = recon_loss * (1 - contrastive_weight) + contrastive_weight * contrast_loss
130
+ optimizer_step(loss)
131
+
132
+ total_loss += loss.item()
133
+ total_recon_loss += recon_loss.item()
134
+ total_contrast_loss += contrast_loss.item()
135
+ batch_count += 1
136
+
137
+ current_lr = optimizer.param_groups[0]['lr']
138
+ progress_info = {
139
+ 'loss': f'{loss.item():.4f}',
140
+ 'lr': f'{current_lr:.2e}',
141
+ 'mask': f'{current_mask_ratio:.3f}',
142
+ 'gpu_mem': f'{torch.cuda.memory_allocated()/1024**2:.0f}MB'
143
+ }
144
+
145
+ if status == 'finetune':
146
+ avg_recon = total_recon_loss / batch_count
147
+ avg_contrast = total_contrast_loss / batch_count
148
+ progress_info.update({
149
+ 'recon': f'{avg_recon:.4f}',
150
+ 'contrast': f'{avg_contrast:.4f}'
151
+ })
152
+
153
+ train_pbar.set_postfix(progress_info)
154
+
155
+ avg_train_loss = total_loss / batch_count if batch_count > 0 else float('inf')
156
+ avg_recon_loss = total_recon_loss / batch_count if batch_count > 0 else 0
157
+ avg_contrast_loss = total_contrast_loss / batch_count if batch_count > 0 else 0
158
+
159
+ # ==================== validation ====================
160
+ model.eval()
161
+ val_loss = 0
162
+ val_recon_loss = 0
163
+ val_contrast_loss = 0
164
+ val_count = 0
165
+
166
+ with torch.no_grad():
167
+ val_pbar = tqdm(val_loader, desc=f"Validation Epoch {epoch+1}")
168
+ for batch in val_pbar:
169
+ if status == 'pretrain':
170
+ src, tgt, _ = batch
171
+ src = src.to(device, non_blocking=True)
172
+ tgt = tgt.to(device, non_blocking=True)
173
+ with torch.amp.autocast(device_type='cuda'):
174
+ _, _, _, recon_loss = model(tgt, mask_ratio=epoch_base_mask, tgt=src)
175
+ loss = recon_loss
176
+ contrast_loss = torch.tensor(0.0)
177
+
178
+ else:
179
+ src, tgt, labels = batch
180
+ src = src.to(device, non_blocking=True)
181
+ tgt = tgt.to(device, non_blocking=True)
182
+ labels = labels.to(device, non_blocking=True)
183
+ src_2 = transforms(src.cpu())
184
+ src2 = src_2.to(device, non_blocking=True)
185
+ with torch.amp.autocast(device_type='cuda'):
186
+ _, embedding, _, recon_loss = model(tgt, mask_ratio=epoch_base_mask, tgt=src)
187
+ _, embedding2, _, _ = model(src2, mask_ratio=epoch_base_mask, tgt=src)
188
+ contrast_loss = contrastive_loss(embedding, embedding2, temperature=temperature)
189
+ loss = recon_loss * (1 - contrastive_weight) + contrastive_weight * contrast_loss
190
+
191
+ val_loss += loss.item()
192
+ val_recon_loss += recon_loss.item()
193
+ val_contrast_loss += contrast_loss.item()
194
+ val_count += 1
195
+
196
+ val_pbar.set_postfix({
197
+ 'val_loss': f'{loss.item():.4f}',
198
+ 'gpu_mem': f'{torch.cuda.memory_allocated()/1024**2:.0f}MB'
199
+ })
200
+
201
+
202
+ avg_val_loss = val_loss / val_count if val_count > 0 else float('inf')
203
+ avg_val_recon = val_recon_loss / val_count if val_count > 0 else 0
204
+ avg_val_contrast = val_contrast_loss / val_count if val_count > 0 else 0
205
+
206
+ # ==================== testing ====================
207
+ test_loss = 0
208
+ test_count = 0
209
+
210
+ with torch.no_grad():
211
+ test_pbar = tqdm(test_loader, desc=f"Testing Epoch {epoch+1}")
212
+ for batch in test_pbar:
213
+
214
+ if status == 'pretrain':
215
+ src, tgt, _ = batch
216
+ src = src.to(device, non_blocking=True)
217
+ tgt = tgt.to(device, non_blocking=True)
218
+ with torch.amp.autocast(device_type='cuda'):
219
+ _, _, _, loss = model(tgt, mask_ratio=epoch_base_mask, tgt=src)
220
+
221
+ else:
222
+ src, tgt, labels = batch
223
+ src = src.to(device, non_blocking=True)
224
+ tgt = tgt.to(device, non_blocking=True)
225
+ labels = labels.to(device, non_blocking=True)
226
+ src_2 = transforms(src.cpu())
227
+ src2 = src_2.to(device, non_blocking=True)
228
+ with torch.amp.autocast(device_type='cuda'):
229
+ _, embedding, _, recon_loss = model(tgt, mask_ratio=epoch_base_mask, tgt=src)
230
+ _, embedding2, _, _= model(src2, mask_ratio=epoch_base_mask, tgt=src)
231
+ contrast_loss = contrastive_loss(embedding, embedding2, temperature=temperature)
232
+ loss = recon_loss * (1 - contrastive_weight) + contrastive_weight * contrast_loss
233
+
234
+ test_loss += loss.item()
235
+ test_count += 1
236
+
237
+ avg_test_loss = test_loss / test_count if test_count > 0 else float('inf')
238
+
239
+ # ==================== learning rate scheduling and early stopping ====================
240
+ current_lr = optimizer.param_groups[0]['lr']
241
+ scheduler.step(avg_val_loss)
242
+ train_losses.append(avg_train_loss)
243
+ val_losses.append(avg_val_loss)
244
+ test_losses.append(avg_test_loss)
245
+ learning_rates.append(current_lr)
246
+ detailed_losses['total'].append(avg_train_loss)
247
+ detailed_losses['recon'].append(avg_recon_loss)
248
+ detailed_losses['contrast'].append(avg_contrast_loss)
249
+ improvement = best_val_loss - avg_val_loss
250
+ if improvement > early_stop_config['min_delta']:
251
+ best_val_loss = avg_val_loss
252
+ best_epoch = epoch
253
+ patience_counter = 0
254
+
255
+ if early_stop_config['restore_best_weights']:
256
+ best_model_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
257
+
258
+ print(f" Epoch {epoch+1}: Validation loss improved by {improvement:.6f} Loss:{avg_val_loss:.6f}")
259
+
260
+ else:
261
+ # No significant improvement
262
+ patience_counter += 1
263
+ print(f" Epoch {epoch+1}: No improvement ({patience_counter}/{early_stop_config['patience']})")
264
+
265
+ print(f" Epoch {epoch+1}/{epochs}:")
266
+ print(f" Training loss: {avg_train_loss:.6f} (Reconstruction: {avg_recon_loss:.6f}, Contrastive: {avg_contrast_loss:.6f})")
267
+ print(f" Validation loss: {avg_val_loss:.6f} (Reconstruction: {avg_val_recon:.6f}, Contrastive: {avg_val_contrast:.6f})")
268
+ print(f" Testing loss: {avg_test_loss:.6f}")
269
+ print(f" Learning rate: {current_lr:.2e}")
270
+ print(f" Best validation loss: {best_val_loss:.6f} (Epoch {best_epoch+1})")
271
+
272
+ if patience_counter >= early_stop_config['patience']:
273
+ print(f"\nEarly stopping triggered!")
274
+ print(f" No significant improvement for {patience_counter} epochs")
275
+ print(f" Best validation loss: {best_val_loss:.6f} (Epoch {best_epoch+1})")
276
+ if early_stop_config['restore_best_weights'] and best_model_state is not None:
277
+ model.load_state_dict({k: v.to(device) for k, v in best_model_state.items()})
278
+ print(f" Restored best model weights (Epoch {best_epoch+1})")
279
+
280
+ break
281
+ if current_lr < scheduler_config['min_lr'] * 1.1:
282
+ print(f"\n Learning rate too low, stopping training early")
283
+ print(f" Current learning rate: {current_lr:.2e}")
284
+ print(f" Minimum learning rate: {scheduler_config['min_lr']:.2e}")
285
+ break
286
+
287
+ torch.cuda.reset_peak_memory_stats()
288
+ torch.cuda.empty_cache()
289
+
290
+ # ==================== Save model and training history ====================
291
+ if save_path:
292
+ save_dir = os.path.dirname(save_path)
293
+ if save_dir and not os.path.exists(save_dir):
294
+ os.makedirs(save_dir, exist_ok=True)
295
+ if status == 'finetune':
296
+ model_name = 'Fine_tuned.pth'
297
+ else:
298
+ model_name = 'Pretexted.pth'
299
+
300
+ model_save_path = os.path.join(save_path, model_name)
301
+
302
+ checkpoint = {
303
+ 'model_state_dict': model.state_dict(),
304
+ 'optimizer_state_dict': optimizer.state_dict(),
305
+ 'scheduler_state_dict': scheduler.state_dict(),
306
+ 'epoch': epoch + 1,
307
+ 'best_val_loss': best_val_loss,
308
+ 'best_epoch': best_epoch + 1,
309
+ 'train_losses': train_losses,
310
+ 'val_losses': val_losses,
311
+ 'test_losses': test_losses,
312
+ 'learning_rates': learning_rates,
313
+ 'detailed_losses': detailed_losses,
314
+ 'early_stop_info': {
315
+ 'triggered': patience_counter >= early_stop_config['patience'],
316
+ 'final_patience_counter': patience_counter,
317
+ 'config': early_stop_config,
318
+ 'scheduler_config': scheduler_config
319
+ },
320
+ 'training_config': {
321
+ 'mask_ratio': mask_ratio,
322
+ 'contrastive_weight': contrastive_weight,
323
+ 'status': status,
324
+ 'use_amp': use_amp
325
+ }
326
+ }
327
+
328
+ torch.save(checkpoint, model_save_path)
329
+ plot_enhanced_training_curves(checkpoint, save_path, status)
330
+
331
+ end_time = time.time()
332
+ total_time = end_time - start_time
333
+ minutes, seconds = divmod(total_time, 60)
334
+
335
+ print(f"\nTraining completed!")
336
+ print(f"Training statistics:")
337
+ print(f"Actual training epochs: {epoch + 1}/{epochs}")
338
+ print(f"Best validation loss: {best_val_loss:.6f}")
339
+ print(f"Best model at epoch: {best_epoch + 1}")
340
+ print(f"Final learning rate: {learning_rates[-1]:.2e}")
341
+ print(f"Early stopping triggered: {'Yes' if patience_counter >= early_stop_config['patience'] else 'No'}")
342
+ print(f"Total training time: {int(minutes)} min {int(seconds)} sec")
343
+
344
+ return model, train_losses
345
+
346
+ def plot_enhanced_training_curves(checkpoint, save_path, status):
347
+ train_losses = checkpoint['train_losses']
348
+ val_losses = checkpoint['val_losses']
349
+ test_losses = checkpoint['test_losses']
350
+ learning_rates = checkpoint['learning_rates']
351
+ detailed_losses = checkpoint['detailed_losses']
352
+ best_epoch = checkpoint['best_epoch'] - 1
353
+
354
+ fig, axes = plt.subplots(2, 2, figsize=(15, 10))
355
+
356
+ axes[0, 0].plot(train_losses, label='Training Loss', color='blue', alpha=0.7)
357
+ axes[0, 0].plot(val_losses, label='Validation Loss', color='red', alpha=0.7)
358
+ axes[0, 0].plot(test_losses, label='Test Loss', color='green', alpha=0.7)
359
+ axes[0, 0].axvline(x=best_epoch, color='orange', linestyle='--',
360
+ label=f'Best Model (Epoch {best_epoch+1})')
361
+ axes[0, 0].set_xlabel('Epoch')
362
+ axes[0, 0].set_ylabel('Loss')
363
+ axes[0, 0].set_title('Loss Curve Comparison')
364
+ axes[0, 0].legend()
365
+ axes[0, 0].grid(True, alpha=0.3)
366
+ axes[0, 1].plot(learning_rates, color='orange', linewidth=2)
367
+ axes[0, 1].set_xlabel('Epoch')
368
+ axes[0, 1].set_ylabel('Learning Rate')
369
+ axes[0, 1].set_title('Learning Rate Scheduling')
370
+ axes[0, 1].set_yscale('log')
371
+ axes[0, 1].grid(True, alpha=0.3)
372
+
373
+ if status == 'finetune' and 'recon' in detailed_losses:
374
+ ax_left = axes[1, 0]
375
+ ax_right = ax_left.twinx()
376
+ l_recon, = ax_left.plot(
377
+ detailed_losses['recon'],
378
+ label='Reconstruction Loss',
379
+ color='blue',
380
+ alpha=0.8
381
+ )
382
+ l_contrast, = ax_right.plot(
383
+ detailed_losses['contrast'],
384
+ label='Contrastive Loss',
385
+ color='red',
386
+ alpha=0.8
387
+ )
388
+ ax_left.set_xlabel('Epoch')
389
+ ax_left.set_ylabel('Reconstruction Loss', color='blue')
390
+ ax_right.set_ylabel('Contrastive Loss', color='red')
391
+ ax_left.tick_params(axis='y', labelcolor='blue')
392
+ ax_right.tick_params(axis='y', labelcolor='red')
393
+ ax_left.set_title('Loss Component Breakdown (Dual Axis)')
394
+ ax_left.grid(True, alpha=0.3)
395
+ ax_left.legend([l_recon, l_contrast], ['Reconstruction Loss', 'Contrastive Loss'], loc='upper right')
396
+ else:
397
+ val_improvements = []
398
+ for i in range(1, len(val_losses)):
399
+ improvement = val_losses[i-1] - val_losses[i]
400
+ val_improvements.append(improvement)
401
+
402
+ axes[1, 0].plot(val_improvements, color='purple', alpha=0.7)
403
+ axes[1, 0].axhline(y=checkpoint['early_stop_info']['config']['min_delta'],
404
+ color='red', linestyle='--', label='Early Stopping Threshold')
405
+ axes[1, 0].set_xlabel('Epoch')
406
+ axes[1, 0].set_ylabel('Validation Loss Improvement')
407
+ axes[1, 0].set_title('Validation Loss Improvement per Epoch')
408
+ axes[1, 0].legend()
409
+ axes[1, 0].grid(True, alpha=0.3)
410
+
411
+ if len(val_losses) > 10:
412
+ window_size = min(10, len(val_losses) // 4)
413
+ moving_avg = np.convolve(val_losses, np.ones(window_size)/window_size, mode='valid')
414
+ axes[1, 1].plot(val_losses, alpha=0.5, label='Original Validation Loss')
415
+ axes[1, 1].plot(range(window_size-1, len(val_losses)), moving_avg,
416
+ linewidth=2, label=f'{window_size}-Point Moving Average')
417
+ axes[1, 1].axvline(x=best_epoch, color='green', linestyle='--',
418
+ label=f'Best Model')
419
+ else:
420
+ axes[1, 1].plot(val_losses, label='Validation Loss')
421
+
422
+ axes[1, 1].set_xlabel('Epoch')
423
+ axes[1, 1].set_ylabel('Loss')
424
+ axes[1, 1].set_title('Convergence Trend Analysis')
425
+ axes[1, 1].legend()
426
+ axes[1, 1].grid(True, alpha=0.3)
427
+
428
+ plt.tight_layout()
429
+ curve_name = 'fine_tuning_curves.png' if status == 'finetune' else 'pretraining_curves.png'
430
+ save_file = os.path.join(save_path, curve_name)
431
+ plt.savefig(save_file, dpi=300, bbox_inches='tight')
432
+ plt.close()
433
+
434
+ def optimize_model_for_gpu(model, device):
435
+ if torch.cuda.is_available() and hasattr(torch.cuda, 'amp'):
436
+ print(" Enable mixed precision training")
437
+ model = model.to(device)
438
+ return model, True
439
+ else:
440
+ model = model.to(device)
441
+ return model, False
442
+
443
+ def main():
444
+ print(device)
445
+ data_dir_QMe14S = '/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/support_file/data/unique_substances_spectra.csv'
446
+ spectra = load_spectra_from_QMe14S(data_dir_QMe14S)
447
+ wavenumbers = np.linspace(0, 3500, spectra.shape[1])
448
+ n_samples = spectra.shape[0]
449
+ seed = 42
450
+ train_size = int(0.8 * n_samples)
451
+ val_size = int(0.1 * n_samples)
452
+ rng = np.random.default_rng(seed)
453
+ shuffled_indices = rng.permutation(n_samples)
454
+ train_indices = shuffled_indices[:train_size]
455
+ val_indices = shuffled_indices[train_size:train_size + val_size]
456
+ test_indices = shuffled_indices[train_size + val_size:]
457
+ Xs_train = spectra[train_indices]
458
+ Xs_val = spectra[val_indices]
459
+ Xs_test = spectra[test_indices]
460
+
461
+ train_transform = get_transforms()
462
+ data_alter = None
463
+ train_labels, val_labels, test_labels = None, None, None
464
+
465
+ train_dataset = RamanDataset(Xs_train, data_alter, labels=train_labels, transform=train_transform, is_train=True)
466
+ train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
467
+
468
+ val_dataset = RamanDataset(Xs_val, data_alter, labels=val_labels, transform=train_transform, is_train=True)
469
+ val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)
470
+
471
+ test_dataset = RamanDataset(Xs_test, data_alter, labels=test_labels, transform=train_transform, is_train=True)
472
+ test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False)
473
+
474
+ para = {"input_length": spectra.shape[1],
475
+ "embedding_dim": 256,
476
+ "num_heads": 8,
477
+ "num_layers": 6,
478
+ "patch_num": 100,
479
+ "epoch": 150,
480
+ "patch_size": spectra.shape[1] // 100,
481
+ "lr_list": [5e-5],
482
+ "mask_ratio": [0.5],
483
+ "model": 'MAE'}
484
+
485
+ input_length = spectra.shape[1]
486
+ embedding_dim = para["embedding_dim"]
487
+ num_heads = para["num_heads"]
488
+ num_layers = para["num_layers"]
489
+ patch_num = para["patch_num"]
490
+ status = 'pretrain'
491
+
492
+ del Xs_train, Xs_val, Xs_test, spectra
493
+ gc.collect()
494
+ for lr in para['lr_list']:
495
+ print(f"Current learning rate: {lr}")
496
+ for mr in para['mask_ratio']:
497
+ print(f"Current mask ratio: {mr}")
498
+
499
+ save_dir = f'/home/lion/Desktop/splendid_lion/project/2025/multi_source/Raman/ms_raman/model/{lr}_{mr}_{embedding_dim}_{num_heads}_{num_layers}_{patch_num}/'
500
+ os.makedirs(save_dir, exist_ok=True)
501
+
502
+ if para['model'] == 'MAE':
503
+ mae_model = MaskedAutoencoderRaman(
504
+ input_length=input_length,
505
+ patch_num=patch_num,
506
+ embed_dim=embedding_dim,
507
+ depth=num_layers,
508
+ num_heads=num_heads,
509
+ decoder_embed_dim=embedding_dim // 2,
510
+ decoder_depth=4,
511
+ decoder_num_heads=num_heads // 2
512
+ )
513
+
514
+ mae_model, use_amp = optimize_model_for_gpu(mae_model, device)
515
+ optimizer = optim.AdamW(mae_model.parameters(), lr=lr, weight_decay=1e-3)
516
+ mae_model, _ = train_mae(
517
+ save_dir,
518
+ status,
519
+ mae_model,
520
+ train_loader,
521
+ val_loader,
522
+ test_loader,
523
+ optimizer,
524
+ device,
525
+ mask_ratio=mr,
526
+ epochs=para['epoch'],
527
+ lr=lr,
528
+ embedding_dim=embedding_dim,
529
+ num_heads=num_heads,
530
+ num_layers=num_layers,
531
+ patch_num=patch_num,
532
+ use_amp=use_amp
533
+ )
534
+
535
+ load_and_visualize_mae_model(
536
+ save_dir,
537
+ status,
538
+ test_dataset,
539
+ device,
540
+ save_dir,
541
+ input_length,
542
+ wavenumbers,
543
+ patch_num=patch_num,
544
+ embedding_dim=embedding_dim,
545
+ num_heads=num_heads,
546
+ num_layers=num_layers
547
+ )
548
+
549
+ del mae_model, optimizer
550
+ torch.cuda.empty_cache()
551
+
552
+ print("Training and evaluation completed!")
553
+ if __name__ == "__main__":
554
+ main()
main/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ matplotlib==3.10.8
2
+ numpy==2.4.1
3
+ optuna==4.6.0
4
+ pandas==3.0.0
5
+ scikit_learn==1.8.0
6
+ scipy==1.17.0
7
+ seaborn==0.13.2
8
+ timm==1.0.24
9
+ torch==2.9.1+rocm6.4
10
+ torchvision==0.24.1+rocm6.4
11
+ tqdm==4.67.1
12
+ umap_learn==0.5.9.post2
webserver/Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /webserver
4
+
5
+ COPY requirements.txt .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # 5. 安全设置:创建一个普通用户运行程序(Hugging Face 推荐)
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV PATH="/home/user/.local/bin:$PATH"
13
+
14
+ # 6. 搬运代码:把当前文件夹所有代码复制到电脑里
15
+ COPY --chown=user . /webserver
16
+
17
+ # 7. 启动:按下“开机键”
18
+ CMD ["uvicorn", "webserver:app", "--host", "0.0.0.0", "--port", "7860"]
webserver/__pycache__/app.cpython-312.pyc ADDED
Binary file (34.1 kB). View file
 
webserver/__pycache__/label_utils.cpython-312.pyc ADDED
Binary file (3.47 kB). View file
 
webserver/__pycache__/preprocess_utils.cpython-312.pyc ADDED
Binary file (7.03 kB). View file
 
webserver/__pycache__/train_service.cpython-312.pyc ADDED
Binary file (19.2 kB). View file
 
webserver/app.py ADDED
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import multiprocessing
3
+ import signal
4
+ import shutil
5
+ import uuid
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from threading import Thread
10
+ from typing import Optional
11
+
12
+ import matplotlib
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import json
17
+ import torch
18
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
19
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
20
+ from fastapi.templating import Jinja2Templates
21
+
22
+ from webserver.train_service import TrainConfig, predict_with_checkpoint, run_finetune_job
23
+ from webserver.label_utils import load_label_mapping, apply_label_mapping
24
+
25
+ BASE_DIR = os.path.dirname(__file__)
26
+ UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
27
+ RUNS_DIR = os.path.join(BASE_DIR, "runs")
28
+ PREDICTIONS_DIR = os.path.join(BASE_DIR, "predictions")
29
+ TEMPLATE_DIR = os.path.join(BASE_DIR, "templates")
30
+
31
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
32
+ os.makedirs(RUNS_DIR, exist_ok=True)
33
+ os.makedirs(PREDICTIONS_DIR, exist_ok=True)
34
+
35
+ app = FastAPI(title="Raman Fine-Tune Webserver")
36
+ templates = Jinja2Templates(directory=TEMPLATE_DIR)
37
+
38
+ if multiprocessing.current_process().name == "MainProcess":
39
+ JOB_MANAGER = multiprocessing.Manager()
40
+ JOBS = JOB_MANAGER.dict()
41
+ else:
42
+ JOB_MANAGER = None
43
+ JOBS = {}
44
+ JOB_PROCESSES = {}
45
+ JOB_CONTEXT = multiprocessing.get_context("spawn")
46
+
47
+
48
+ def _save_upload(file_obj: UploadFile, dst_path: str):
49
+ with open(dst_path, "wb") as out:
50
+ shutil.copyfileobj(file_obj.file, out)
51
+
52
+
53
+ def _load_report_text(report_path: str):
54
+ if not os.path.isfile(report_path):
55
+ return None
56
+ with open(report_path, "r", encoding="utf-8") as f:
57
+ return f.read()
58
+
59
+
60
+ def _build_artifact_entries(base_dir: str, artifact_map: dict, route_prefix: str):
61
+ entries = []
62
+ for key, filename in artifact_map.items():
63
+ file_path = os.path.join(base_dir, filename)
64
+ if not os.path.isfile(file_path):
65
+ continue
66
+ entries.append(
67
+ {
68
+ "key": key,
69
+ "filename": filename,
70
+ "url": f"/{route_prefix}/{os.path.basename(base_dir)}/{filename}",
71
+ "is_image": filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")),
72
+ "is_text": filename.lower().endswith((".txt", ".json", ".csv")),
73
+ }
74
+ )
75
+ return entries
76
+
77
+
78
+ def _safe_result_file(root_dir: str, item_id: str, filename: str):
79
+ safe_name = os.path.basename(filename)
80
+ folder = os.path.join(root_dir, item_id)
81
+ file_path = os.path.join(folder, safe_name)
82
+ if not os.path.isfile(file_path):
83
+ raise HTTPException(status_code=404, detail="File not found")
84
+ return file_path
85
+
86
+
87
+ def _safe_uploaded_name(filename: str) -> str:
88
+ safe_name = os.path.basename(filename or "")
89
+ if not safe_name:
90
+ raise HTTPException(status_code=400, detail="Uploaded file is missing a filename")
91
+ return safe_name
92
+
93
+
94
+ def _is_optional_file(upload: Optional[UploadFile]) -> bool:
95
+ return upload is None or not getattr(upload, "filename", "") or not str(upload.filename).strip()
96
+
97
+
98
+ def _is_blank_upload(upload: Optional[UploadFile]) -> bool:
99
+ return upload is None or not getattr(upload, "filename", "") or not str(upload.filename).strip()
100
+
101
+
102
+ def _render_predict_results_fragment(
103
+ prediction_id: str,
104
+ summary: dict,
105
+ rows: list[dict],
106
+ top5_rows: list[dict],
107
+ download_csv: str,
108
+ preview_image: str,
109
+ ):
110
+ return templates.env.get_template("predict_result_fragment.html").render(
111
+ prediction_id=prediction_id,
112
+ summary=summary,
113
+ rows=rows,
114
+ top5_rows=top5_rows,
115
+ download_csv=download_csv,
116
+ preview_image=preview_image,
117
+ )
118
+
119
+
120
+ def _parse_numeric_text_file(file_path: str) -> np.ndarray:
121
+ rows = []
122
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
123
+ for raw_line in f:
124
+ line = raw_line.strip()
125
+ if not line or line.startswith("#"):
126
+ continue
127
+ tokens = [token for token in re.split(r"[\s,]+", line) if token]
128
+ values = []
129
+ for token in tokens:
130
+ try:
131
+ values.append(float(token))
132
+ except ValueError:
133
+ continue
134
+ if values:
135
+ rows.append(values)
136
+
137
+ if not rows:
138
+ raise ValueError("No numeric data found in text file")
139
+
140
+ max_cols = max(len(row) for row in rows)
141
+ if max_cols == 1:
142
+ return np.asarray([row[0] for row in rows], dtype=np.float32)
143
+
144
+ return np.asarray([[row[0], row[1]] for row in rows if len(row) >= 2], dtype=np.float32)
145
+
146
+
147
+ def _load_prediction_spectrum(file_path: str) -> tuple[np.ndarray, Optional[np.ndarray], str]:
148
+ extension = os.path.splitext(file_path)[1].lower()
149
+ if extension in {".txt", ".csv"}:
150
+ data = _parse_numeric_text_file(file_path)
151
+ if data.ndim == 1:
152
+ spectra = data.astype(np.float32).reshape(1, -1)
153
+ return spectra, None, "text_intensity_only"
154
+
155
+ if data.ndim == 2 and data.shape[1] >= 2:
156
+ wavenumbers = data[:, 0].astype(np.float32)
157
+ spectra = data[:, 1].astype(np.float32).reshape(1, -1)
158
+ return spectra, wavenumbers, "text_wavenumber_intensity"
159
+
160
+ raise ValueError("Text spectrum must contain either one intensity column or two columns: wavenumber, intensity")
161
+
162
+ if extension == ".npy":
163
+ spectra = np.load(file_path, allow_pickle=True)
164
+ return np.asarray(spectra, dtype=np.float32), None, "npy"
165
+
166
+ raise ValueError("Spectrum file must be .txt, .csv, or .npy")
167
+
168
+
169
+ def _load_prediction_wavenumbers(file_path: str) -> np.ndarray:
170
+ extension = os.path.splitext(file_path)[1].lower()
171
+ if extension in {".txt", ".csv"}:
172
+ data = _parse_numeric_text_file(file_path)
173
+ if data.ndim == 1:
174
+ return data.astype(np.float32).reshape(-1)
175
+ if data.ndim == 2 and data.shape[1] >= 1:
176
+ return data[:, 0].astype(np.float32).reshape(-1)
177
+ raise ValueError("Wavelength text file must contain one numeric column")
178
+
179
+ if extension == ".npy":
180
+ return np.asarray(np.load(file_path, allow_pickle=True), dtype=np.float32).reshape(-1)
181
+
182
+ raise ValueError("Wavelength file must be .txt, .csv, or .npy")
183
+
184
+
185
+ def _build_manual_wavenumbers(length: int, low_cm: float, high_cm: float) -> np.ndarray:
186
+ if low_cm is None or high_cm is None:
187
+ raise ValueError("Manual wavelength range requires both low and high values")
188
+ if high_cm <= low_cm:
189
+ raise ValueError("Manual wavelength range high value must be greater than low value")
190
+ return np.linspace(float(low_cm), float(high_cm), int(length), dtype=np.float32)
191
+
192
+
193
+ def _save_prediction_preview(prediction_dir: str, target_wavenumbers: np.ndarray, processed_spectra: np.ndarray) -> str:
194
+ spectra = np.asarray(processed_spectra, dtype=np.float32)
195
+ wavenumbers = np.asarray(target_wavenumbers, dtype=np.float32).reshape(-1)
196
+ if spectra.ndim != 2 or spectra.shape[1] != wavenumbers.shape[0]:
197
+ raise ValueError("processed spectra and wavenumbers must have matching 2D/1D shapes")
198
+
199
+ sample_count = spectra.shape[0]
200
+ preview_count = min(sample_count, 6)
201
+ fig, ax = plt.subplots(figsize=(8, 4.5))
202
+ for idx in range(preview_count):
203
+ label = f"Sample {idx + 1}" if sample_count > 1 else "Input spectrum"
204
+ ax.plot(wavenumbers, spectra[idx], linewidth=1.0, alpha=0.9, label=label)
205
+
206
+ ax.set_title(f"Input Spectra Preview ({sample_count} sample{'s' if sample_count != 1 else ''})")
207
+ ax.set_xlabel("Wavenumber (cm$^{-1}$)")
208
+ ax.set_ylabel("Normalized intensity")
209
+ ax.set_xlim(float(wavenumbers.min()), float(wavenumbers.max()))
210
+ ax.grid(True, linestyle="--", alpha=0.3)
211
+ if preview_count > 1:
212
+ ax.legend(frameon=False, fontsize=8)
213
+ fig.tight_layout()
214
+
215
+ preview_path = os.path.join(prediction_dir, "input_spectra_preview.png")
216
+ fig.savefig(preview_path, dpi=300, bbox_inches="tight")
217
+ plt.close(fig)
218
+ return preview_path
219
+
220
+
221
+ def _reap_job_process(job_id: str, process: multiprocessing.Process):
222
+ process.join()
223
+ JOB_PROCESSES.pop(job_id, None)
224
+
225
+
226
+ @app.get("/")
227
+ def index(request: Request):
228
+ return templates.TemplateResponse(request, "index.html", {"request": request})
229
+
230
+
231
+ @app.get("/predict")
232
+ def predict_page(request: Request):
233
+ return templates.TemplateResponse(request, "predict.html", {"request": request})
234
+
235
+
236
+ @app.post("/start")
237
+ def start_job(
238
+ request: Request,
239
+ spectral_file: UploadFile = File(...),
240
+ labels_file: UploadFile = File(...),
241
+ wavenumbers_file: UploadFile = File(...),
242
+ model_file: UploadFile = File(...),
243
+ label_mapping_file: Optional[UploadFile] = File(None),
244
+ epochs: int = Form(60),
245
+ lr: float = Form(1e-4),
246
+ weight_decay: float = Form(1e-3),
247
+ patience: int = Form(12),
248
+ batch_size: int = Form(64),
249
+ patch_num: int = Form(100),
250
+ embedding_dim: int = Form(512),
251
+ num_layers: int = Form(12),
252
+ num_heads: int = Form(16),
253
+ freeze_encoder: bool = Form(False),
254
+ label_smoothing: float = Form(0.0),
255
+ ):
256
+ if _is_optional_file(label_mapping_file):
257
+ label_mapping_file = None
258
+
259
+ for f in [spectral_file, labels_file, wavenumbers_file, model_file] + ([label_mapping_file] if label_mapping_file is not None else []):
260
+ if not f.filename.endswith(".npy") and f is not model_file:
261
+ if f is label_mapping_file and os.path.splitext(f.filename)[1].lower() not in {".json", ".txt"}:
262
+ raise HTTPException(status_code=400, detail=f"{f.filename} must be .json or .txt")
263
+ elif f is not label_mapping_file:
264
+ raise HTTPException(status_code=400, detail=f"{f.filename} must be .npy")
265
+ if f is model_file and not f.filename.endswith(".pth"):
266
+ raise HTTPException(status_code=400, detail="Model must be .pth")
267
+
268
+ job_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
269
+ job_upload_dir = os.path.join(UPLOAD_DIR, job_id)
270
+ job_run_dir = os.path.join(RUNS_DIR, job_id)
271
+ os.makedirs(job_upload_dir, exist_ok=True)
272
+ os.makedirs(job_run_dir, exist_ok=True)
273
+
274
+ spectral_path = os.path.join(job_upload_dir, "spectral.npy")
275
+ labels_path = os.path.join(job_upload_dir, "labels.npy")
276
+ wavenumbers_path = os.path.join(job_upload_dir, "wavenumbers.npy")
277
+ model_path = os.path.join(job_upload_dir, "model.pth")
278
+ label_mapping_path = os.path.join(job_upload_dir, _safe_uploaded_name(label_mapping_file.filename)) if label_mapping_file is not None else None
279
+
280
+ _save_upload(spectral_file, spectral_path)
281
+ _save_upload(labels_file, labels_path)
282
+ _save_upload(wavenumbers_file, wavenumbers_path)
283
+ _save_upload(model_file, model_path)
284
+ if label_mapping_file is not None:
285
+ _save_upload(label_mapping_file, label_mapping_path)
286
+
287
+ config = TrainConfig(
288
+ epochs=epochs,
289
+ lr=lr,
290
+ weight_decay=weight_decay,
291
+ patience=patience,
292
+ batch_size=batch_size,
293
+ patch_num=patch_num,
294
+ embedding_dim=embedding_dim,
295
+ num_layers=num_layers,
296
+ num_heads=num_heads,
297
+ freeze_encoder=freeze_encoder,
298
+ label_smoothing=label_smoothing,
299
+ )
300
+
301
+ input_paths = {
302
+ "spectral": spectral_path,
303
+ "labels": labels_path,
304
+ "wavenumbers": wavenumbers_path,
305
+ "model": model_path,
306
+ "label_mapping": label_mapping_path,
307
+ }
308
+
309
+ JOBS[job_id] = {
310
+ "status": "queued",
311
+ "message": "Job queued",
312
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
313
+ "progress": 0,
314
+ "phase": "queued",
315
+ "current_epoch": 0,
316
+ "total_epochs": epochs,
317
+ "device_label": "Detecting...",
318
+ "device_backend": "",
319
+ "device_name": "",
320
+ }
321
+
322
+ process = JOB_CONTEXT.Process(
323
+ target=run_finetune_job,
324
+ args=(job_id, input_paths, job_run_dir, config, JOBS),
325
+ daemon=False,
326
+ )
327
+ process.start()
328
+ JOB_PROCESSES[job_id] = process
329
+ Thread(target=_reap_job_process, args=(job_id, process), daemon=True).start()
330
+ job_record = dict(JOBS[job_id])
331
+ job_record["pid"] = process.pid
332
+ JOBS[job_id] = job_record
333
+
334
+ if request.headers.get("accept", "").find("application/json") >= 0 or request.headers.get("x-requested-with") == "XMLHttpRequest":
335
+ return JSONResponse({"job_id": job_id, "status_url": f"/status/{job_id}", "stop_url": f"/stop/{job_id}"})
336
+
337
+ return RedirectResponse(url=f"/status/{job_id}", status_code=303)
338
+
339
+
340
+ @app.post("/stop/{job_id}")
341
+ def stop_job(job_id: str):
342
+ if job_id not in JOBS:
343
+ raise HTTPException(status_code=404, detail="Job not found")
344
+
345
+ job = dict(JOBS[job_id])
346
+ if job.get("status") in {"done", "error", "cancelled"}:
347
+ raise HTTPException(status_code=409, detail="Job is already finished")
348
+
349
+ process = JOB_PROCESSES.get(job_id)
350
+ if process is not None:
351
+ if process.is_alive():
352
+ process.terminate()
353
+ process.join(timeout=5)
354
+ if process.is_alive():
355
+ process.kill()
356
+ process.join(timeout=5)
357
+ else:
358
+ pid = job.get("pid")
359
+ if pid:
360
+ try:
361
+ os.kill(int(pid), signal.SIGTERM)
362
+ except ProcessLookupError:
363
+ pass
364
+
365
+ JOBS[job_id] = {
366
+ **job,
367
+ "status": "cancelled",
368
+ "message": "Job cancelled by user",
369
+ "phase": "cancelled",
370
+ "progress": min(int(job.get("progress", 0) or 0), 99),
371
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
372
+ }
373
+ return JSONResponse({"job_id": job_id, "status": "cancelled"})
374
+
375
+
376
+ @app.post("/predict")
377
+ def run_prediction(
378
+ request: Request,
379
+ spectral_file: UploadFile = File(...),
380
+ wavenumbers_file: Optional[UploadFile] = File(None),
381
+ model_file: UploadFile = File(...),
382
+ label_mapping_file: Optional[UploadFile] = File(None),
383
+ manual_low_cm: Optional[float] = Form(None),
384
+ manual_high_cm: Optional[float] = Form(None),
385
+ ):
386
+ if _is_blank_upload(spectral_file):
387
+ raise HTTPException(status_code=400, detail="Please choose a spectral file before running prediction.")
388
+ if _is_blank_upload(model_file):
389
+ raise HTTPException(status_code=400, detail="Please choose a saved model (.pth) before running prediction.")
390
+
391
+ if _is_blank_upload(wavenumbers_file):
392
+ wavenumbers_file = None
393
+
394
+ if _is_optional_file(label_mapping_file):
395
+ label_mapping_file = None
396
+
397
+ spectral_name = _safe_uploaded_name(spectral_file.filename)
398
+ model_name = _safe_uploaded_name(model_file.filename)
399
+ wavenumbers_name = _safe_uploaded_name(wavenumbers_file.filename) if wavenumbers_file is not None else None
400
+ label_mapping_name = _safe_uploaded_name(label_mapping_file.filename) if label_mapping_file is not None else None
401
+
402
+ if os.path.splitext(model_name)[1].lower() != ".pth":
403
+ raise HTTPException(status_code=400, detail="Model file must be .pth")
404
+
405
+ spectral_ext = os.path.splitext(spectral_name)[1].lower()
406
+ if spectral_ext not in {".npy", ".txt", ".csv"}:
407
+ raise HTTPException(status_code=400, detail="Spectral file must be .npy, .txt, or .csv")
408
+
409
+ if wavenumbers_file is not None:
410
+ wavenumbers_ext = os.path.splitext(wavenumbers_name or "")[1].lower()
411
+ if wavenumbers_ext not in {".npy", ".txt", ".csv"}:
412
+ raise HTTPException(status_code=400, detail="Wavelength file must be .npy, .txt, or .csv")
413
+
414
+ if label_mapping_file is not None:
415
+ label_mapping_ext = os.path.splitext(label_mapping_name or "")[1].lower()
416
+ if label_mapping_ext not in {".json", ".txt"}:
417
+ raise HTTPException(status_code=400, detail="True label mapping file must be .json or .txt")
418
+
419
+ prediction_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
420
+ prediction_dir = os.path.join(PREDICTIONS_DIR, prediction_id)
421
+ os.makedirs(prediction_dir, exist_ok=True)
422
+
423
+ spectral_path = os.path.join(prediction_dir, spectral_name)
424
+ wavenumbers_path = os.path.join(prediction_dir, wavenumbers_name) if wavenumbers_name is not None else None
425
+ model_path = os.path.join(prediction_dir, model_name)
426
+ label_mapping_path = os.path.join(prediction_dir, label_mapping_name) if label_mapping_name is not None else None
427
+
428
+ _save_upload(spectral_file, spectral_path)
429
+ _save_upload(model_file, model_path)
430
+ if wavenumbers_file is not None:
431
+ _save_upload(wavenumbers_file, wavenumbers_path)
432
+ if label_mapping_file is not None:
433
+ _save_upload(label_mapping_file, label_mapping_path)
434
+
435
+ display_label_mapping = None
436
+ if label_mapping_path is not None:
437
+ display_label_mapping = load_label_mapping(label_mapping_path)
438
+
439
+ try:
440
+ spectral, inferred_wavenumbers, spectrum_source = _load_prediction_spectrum(spectral_path)
441
+
442
+ if inferred_wavenumbers is not None:
443
+ wavenumbers = inferred_wavenumbers
444
+ wavenumber_source = "embedded_in_spectrum"
445
+ elif wavenumbers_file is not None:
446
+ wavenumbers = _load_prediction_wavenumbers(wavenumbers_path)
447
+ wavenumber_source = "uploaded_wavelength_file"
448
+ elif manual_low_cm is not None or manual_high_cm is not None:
449
+ if manual_low_cm is None or manual_high_cm is None:
450
+ raise ValueError("Manual wavelength range requires both low and high values")
451
+ wavenumbers = _build_manual_wavenumbers(spectral.shape[-1], manual_low_cm, manual_high_cm)
452
+ wavenumber_source = "manual_range"
453
+ else:
454
+ raise HTTPException(
455
+ status_code=400,
456
+ detail="No wavelength information found in the spectrum file. Upload a wavelength file or provide a manual wavelength range.",
457
+ )
458
+
459
+ if spectral.ndim == 1:
460
+ spectral = spectral.reshape(1, -1)
461
+ if spectral.ndim != 2:
462
+ raise ValueError(f"Spectrum data must be 1D or 2D after loading, got shape {spectral.shape}")
463
+
464
+ preview_path = _save_prediction_preview(prediction_dir, wavenumbers, spectral)
465
+ except ValueError as exc:
466
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
467
+
468
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
469
+
470
+ try:
471
+ results = predict_with_checkpoint(model_path, spectral, wavenumbers, device, display_label_mapping=display_label_mapping)
472
+ except (ValueError, RuntimeError) as exc:
473
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
474
+
475
+ # Ensure we display mapped (human) labels when available. Prefer explicit display mapping
476
+ display_class_names = apply_label_mapping(
477
+ results.get("raw_class_names", results.get("class_names", [])),
478
+ display_label_mapping or results.get("checkpoint_label_mapping"),
479
+ )
480
+
481
+ top5_rows = []
482
+ top5_indices = np.argsort(results["logits"], axis=1)[:, ::-1][:, : min(5, results["logits"].shape[1])]
483
+ top5_logits = np.take_along_axis(results["logits"], top5_indices, axis=1)
484
+ for idx, (indices_row, logits_row) in enumerate(zip(top5_indices, top5_logits), start=1):
485
+ top5_rows.append(
486
+ {
487
+ "sample_index": idx,
488
+ "top5": [
489
+ {
490
+ "rank": rank + 1,
491
+ "class_name": display_class_names[class_idx] if class_idx < len(display_class_names) else str(class_idx),
492
+ "logit": float(logit_value),
493
+ }
494
+ for rank, (class_idx, logit_value) in enumerate(zip(indices_row.tolist(), logits_row.tolist()))
495
+ ],
496
+ }
497
+ )
498
+
499
+ rows = []
500
+ for idx, (pred_index, confidence) in enumerate(
501
+ zip(results["pred_indices"], results["confidences"]),
502
+ start=1,
503
+ ):
504
+ pred_index = int(pred_index)
505
+ pred_label_display = display_class_names[pred_index] if pred_index < len(display_class_names) else str(pred_index)
506
+ rows.append(
507
+ {
508
+ "sample_index": idx,
509
+ "pred_index": pred_index,
510
+ "pred_label": pred_label_display,
511
+ "confidence": float(confidence),
512
+ }
513
+ )
514
+
515
+ csv_path = os.path.join(prediction_dir, "predictions.csv")
516
+ with open(csv_path, "w", encoding="utf-8") as f:
517
+ f.write("sample_index,predicted_index,predicted_label,confidence\n")
518
+ for row in rows:
519
+ f.write(
520
+ f"{row['sample_index']},{row['pred_index']},{row['pred_label']},{row['confidence']:.6f}\n"
521
+ )
522
+
523
+ summary = {
524
+ "prediction_id": prediction_id,
525
+ "num_samples": len(rows),
526
+ "class_names": results["class_names"],
527
+ "raw_class_names": results.get("raw_class_names", []),
528
+ "model_config": results["model_config"],
529
+ "preprocess_config": results["preprocess_config"],
530
+ "download_csv": f"/predictions/{prediction_id}/predictions.csv",
531
+ "preview_image": f"/predictions/{prediction_id}/{os.path.basename(preview_path)}",
532
+ "spectrum_source": spectrum_source,
533
+ "wavenumber_source": wavenumber_source,
534
+ "label_mapping_source": label_mapping_name or ("checkpoint" if results.get("checkpoint_label_mapping") else None),
535
+ }
536
+ with open(os.path.join(prediction_dir, "prediction_summary.json"), "w", encoding="utf-8") as f:
537
+ json.dump(summary, f, indent=2, ensure_ascii=False)
538
+
539
+ if request.headers.get("accept", "").find("application/json") >= 0 or request.headers.get("x-requested-with") == "XMLHttpRequest":
540
+ return JSONResponse(
541
+ {
542
+ "prediction_id": prediction_id,
543
+ "summary": summary,
544
+ "rows": rows,
545
+ "top5_rows": top5_rows,
546
+ "download_csv": summary["download_csv"],
547
+ "preview_image": summary["preview_image"],
548
+ "results_html": _render_predict_results_fragment(
549
+ prediction_id,
550
+ summary,
551
+ rows,
552
+ top5_rows,
553
+ summary["download_csv"],
554
+ summary["preview_image"],
555
+ ),
556
+ }
557
+ )
558
+
559
+ return templates.TemplateResponse(
560
+ request,
561
+ "predict.html",
562
+ {
563
+ "request": request,
564
+ "prediction_id": prediction_id,
565
+ "summary": summary,
566
+ "rows": rows,
567
+ "top5_rows": top5_rows,
568
+ "download_csv": summary["download_csv"],
569
+ "preview_image": summary["preview_image"],
570
+ },
571
+ )
572
+
573
+
574
+ @app.get("/status/{job_id}")
575
+ def status_page(job_id: str, request: Request):
576
+ if job_id not in JOBS:
577
+ raise HTTPException(status_code=404, detail="Job not found")
578
+ job = {
579
+ "status": "queued",
580
+ "message": "Job queued",
581
+ "updated_at": None,
582
+ "progress": 0,
583
+ "phase": "queued",
584
+ "current_epoch": 0,
585
+ "total_epochs": 0,
586
+ "device_label": "Detecting...",
587
+ "device_backend": "",
588
+ "device_name": "",
589
+ **JOBS[job_id],
590
+ }
591
+ if not job.get("total_epochs"):
592
+ job["total_epochs"] = 0
593
+ can_stop = job.get("status") in {"queued", "running"}
594
+ summary = job.get("summary", {}) or {}
595
+ artifact_map = summary.get("artifacts", {}) or {}
596
+ run_dir = os.path.join(RUNS_DIR, job_id)
597
+ report_path = os.path.join(run_dir, artifact_map.get("classification_report", "classification_report.txt"))
598
+
599
+ visual_keys = ["training_history", "tsne", "confusion_matrix"]
600
+ download_keys = [
601
+ "training_history",
602
+ "tsne",
603
+ "confusion_matrix",
604
+ "roc_curves",
605
+ "classification_report",
606
+ "final_model",
607
+ "best_class_model",
608
+ "best_recon_model",
609
+ ]
610
+
611
+ visual_artifacts = []
612
+ download_artifacts = []
613
+ for key in visual_keys + download_keys:
614
+ filename = artifact_map.get(key)
615
+ if not filename:
616
+ continue
617
+ file_path = os.path.join(run_dir, filename)
618
+ if not os.path.isfile(file_path):
619
+ continue
620
+ artifact_info = {
621
+ "key": key,
622
+ "filename": filename,
623
+ "url": f"/runs/{job_id}/{filename}",
624
+ "is_image": filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")),
625
+ }
626
+ if key in visual_keys and artifact_info["is_image"]:
627
+ visual_artifacts.append(artifact_info)
628
+ if key in download_keys:
629
+ download_artifacts.append(artifact_info)
630
+
631
+ return templates.TemplateResponse(
632
+ request,
633
+ "status.html",
634
+ {
635
+ "request": request,
636
+ "job_id": job_id,
637
+ "job": job,
638
+ "summary": summary,
639
+ "can_stop": can_stop,
640
+ "visual_artifacts": visual_artifacts,
641
+ "download_artifacts": download_artifacts,
642
+ "report_text": _load_report_text(report_path),
643
+ },
644
+ )
645
+
646
+
647
+ @app.get("/api/status/{job_id}")
648
+ def status_api(job_id: str):
649
+ if job_id not in JOBS:
650
+ raise HTTPException(status_code=404, detail="Job not found")
651
+ return JOBS[job_id]
652
+
653
+
654
+ @app.get("/runs/{job_id}/{filename}")
655
+ def job_artifact(job_id: str, filename: str):
656
+ file_path = _safe_result_file(RUNS_DIR, job_id, filename)
657
+ return FileResponse(file_path, filename=os.path.basename(file_path))
658
+
659
+
660
+ @app.get("/predictions/{prediction_id}/{filename}")
661
+ def prediction_artifact(prediction_id: str, filename: str):
662
+ file_path = _safe_result_file(PREDICTIONS_DIR, prediction_id, filename)
663
+ return FileResponse(file_path, filename=os.path.basename(file_path))
webserver/label_utils.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Iterable, Sequence
4
+
5
+
6
+ def load_label_mapping(file_path: str) -> list[str]:
7
+ extension = os.path.splitext(file_path)[1].lower()
8
+ if extension == ".txt":
9
+ labels: list[str] = []
10
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
11
+ for raw_line in f:
12
+ line = raw_line.strip()
13
+ if not line or line.startswith("#"):
14
+ continue
15
+ labels.append(line)
16
+ if not labels:
17
+ raise ValueError("Label mapping file does not contain any labels")
18
+ return labels
19
+
20
+ if extension == ".json":
21
+ with open(file_path, "r", encoding="utf-8") as f:
22
+ data = json.load(f)
23
+
24
+ if isinstance(data, list):
25
+ labels = [str(item).strip() for item in data]
26
+ if not any(labels):
27
+ raise ValueError("Label mapping JSON list is empty")
28
+ return labels
29
+
30
+ if isinstance(data, dict):
31
+ indexed_labels: list[tuple[int, str]] = []
32
+ for key, value in data.items():
33
+ try:
34
+ index = int(key)
35
+ except (TypeError, ValueError) as exc:
36
+ raise ValueError("Label mapping JSON object keys must be numeric indices") from exc
37
+ indexed_labels.append((index, str(value).strip()))
38
+
39
+ if not indexed_labels:
40
+ raise ValueError("Label mapping JSON object is empty")
41
+
42
+ max_index = max(index for index, _ in indexed_labels)
43
+ labels = [""] * (max_index + 1)
44
+ for index, label in indexed_labels:
45
+ labels[index] = label
46
+ return labels
47
+
48
+ raise ValueError("Label mapping JSON must be a list or an object")
49
+
50
+ raise ValueError("Label mapping file must be .json or .txt")
51
+
52
+
53
+ def apply_label_mapping(base_labels: Sequence[str], mapping: Sequence[str] | None) -> list[str]:
54
+ labels = [str(label) for label in base_labels]
55
+ if not mapping:
56
+ return labels
57
+
58
+ mapped = list(labels)
59
+ for index, mapped_label in enumerate(mapping):
60
+ if index >= len(mapped):
61
+ break
62
+ value = str(mapped_label).strip()
63
+ if value:
64
+ mapped[index] = value
65
+ return mapped
webserver/preprocess_utils.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+
4
+ def ensure_2d_spectra(spectra: np.ndarray) -> np.ndarray:
5
+ arr = np.asarray(spectra)
6
+ if arr.ndim == 3 and 1 in arr.shape:
7
+ return arr.reshape(arr.shape[0], -1)
8
+ if arr.ndim != 2:
9
+ raise ValueError(f"spectral array must be 2D [N, L], got shape={arr.shape}")
10
+ return arr
11
+
12
+
13
+ def build_target_wavenumbers(target_len: int = 3500) -> np.ndarray:
14
+ # 3500 samples spanning [0, 3500] cm^-1, matching the requested paper workflow.
15
+ return np.linspace(0.0, 3500.0, target_len, dtype=np.float32)
16
+
17
+
18
+ def preprocess_raman_spectra(
19
+ spectra: np.ndarray,
20
+ wavenumbers: np.ndarray,
21
+ target_len: int = 3500,
22
+ low_cm: float = 0.0,
23
+ high_cm: float = 3500.0,
24
+ eps_fill: float = 1e-8,
25
+ ):
26
+ spectra = ensure_2d_spectra(np.asarray(spectra, dtype=np.float32))
27
+ wavenumbers = np.asarray(wavenumbers, dtype=np.float32).reshape(-1)
28
+
29
+ target_w = build_target_wavenumbers(target_len=target_len)
30
+
31
+ valid = np.isfinite(wavenumbers)
32
+ w = wavenumbers[valid]
33
+ x = spectra[:, valid]
34
+
35
+ if w.size < 2:
36
+ raise ValueError("wavenumbers must contain at least 2 finite values")
37
+
38
+ order = np.argsort(w)
39
+ w = w[order]
40
+ x = x[:, order]
41
+
42
+ in_range = (w >= low_cm) & (w <= high_cm)
43
+ if np.any(in_range):
44
+ w = w[in_range]
45
+ x = x[:, in_range]
46
+
47
+ if w.size < 2:
48
+ raise ValueError("wavenumbers in [0, 3500] are insufficient for interpolation")
49
+
50
+ w_unique, unique_idx = np.unique(w, return_index=True)
51
+ x = x[:, unique_idx]
52
+
53
+ interpolated = np.empty((x.shape[0], target_len), dtype=np.float32)
54
+ for i in range(x.shape[0]):
55
+ interpolated[i] = np.interp(
56
+ target_w,
57
+ w_unique,
58
+ x[i],
59
+ left=eps_fill,
60
+ right=eps_fill,
61
+ )
62
+
63
+ mins = interpolated.min(axis=1, keepdims=True)
64
+ maxs = interpolated.max(axis=1, keepdims=True)
65
+ denom = np.where((maxs - mins) < 1e-12, 1.0, maxs - mins)
66
+ normalized = (interpolated - mins) / denom
67
+
68
+ return normalized.astype(np.float32), target_w
69
+
70
+
71
+ def preprocess_raman_dataset(
72
+ spectra: np.ndarray,
73
+ labels: np.ndarray,
74
+ wavenumbers: np.ndarray,
75
+ target_len: int = 3500,
76
+ low_cm: float = 0.0,
77
+ high_cm: float = 3500.0,
78
+ eps_fill: float = 1e-8,
79
+ ):
80
+ labels = np.asarray(labels)
81
+
82
+ spectra, target_w = preprocess_raman_spectra(
83
+ spectra,
84
+ wavenumbers,
85
+ target_len=target_len,
86
+ low_cm=low_cm,
87
+ high_cm=high_cm,
88
+ eps_fill=eps_fill,
89
+ )
90
+
91
+ if spectra.shape[0] != labels.shape[0]:
92
+ raise ValueError(
93
+ f"spectral/labels length mismatch: {spectra.shape[0]} vs {labels.shape[0]}"
94
+ )
95
+
96
+ return spectra.astype(np.float32), labels, target_w
97
+
98
+
99
+ def augment_small_trainset(
100
+ x_train: np.ndarray,
101
+ y_train: np.ndarray,
102
+ target_per_class: int = 100,
103
+ seed: int = 42,
104
+ ) -> tuple[np.ndarray, np.ndarray]:
105
+ rng = np.random.default_rng(seed)
106
+ x_train = np.asarray(x_train, dtype=np.float32)
107
+ y_train = np.asarray(y_train)
108
+
109
+ out_x = [x_train]
110
+ out_y = [y_train]
111
+
112
+ unique_classes = np.unique(y_train)
113
+ for cls in unique_classes:
114
+ cls_idx = np.where(y_train == cls)[0]
115
+ cls_samples = x_train[cls_idx]
116
+ if cls_samples.shape[0] >= target_per_class:
117
+ continue
118
+
119
+ need = target_per_class - cls_samples.shape[0]
120
+ synth = []
121
+ for _ in range(need):
122
+ src = cls_samples[rng.integers(0, cls_samples.shape[0])].copy()
123
+ noise = rng.normal(0.0, 0.01, size=src.shape).astype(np.float32)
124
+ scale = rng.uniform(0.95, 1.05)
125
+ shift = int(rng.integers(-3, 4))
126
+
127
+ aug = np.roll(src * scale + noise, shift)
128
+ aug = np.clip(aug, 0.0, 1.0)
129
+ synth.append(aug)
130
+
131
+ if synth:
132
+ synth = np.asarray(synth, dtype=np.float32)
133
+ out_x.append(synth)
134
+ out_y.append(np.full((synth.shape[0],), cls, dtype=y_train.dtype))
135
+
136
+ x_aug = np.concatenate(out_x, axis=0)
137
+ y_aug = np.concatenate(out_y, axis=0)
138
+
139
+ perm = rng.permutation(len(x_aug))
140
+ return x_aug[perm], y_aug[perm]
webserver/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ jinja2
5
+ numpy
6
+ torch
7
+ scikit-learn
8
+ matplotlib
webserver/templates/index.html ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>GEMS Raman Webserver</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f4f0e8;
11
+ --panel: rgba(255, 255, 255, 0.88);
12
+ --panel-border: rgba(33, 37, 41, 0.12);
13
+ --text: #1f2937;
14
+ --muted: #5b6472;
15
+ --accent: #2563a8;
16
+ --accent-strong: #1e40af;
17
+ --shadow: 0 18px 50px rgba(32, 41, 48, 0.12);
18
+ }
19
+ body {
20
+ margin: 0;
21
+ font-family: "Segoe UI", Tahoma, sans-serif;
22
+ color: var(--text);
23
+ background:
24
+ radial-gradient(circle at top left, rgba(37, 99, 168, 0.14), transparent 28%),
25
+ radial-gradient(circle at top right, rgba(186, 92, 39, 0.12), transparent 24%),
26
+ linear-gradient(180deg, #faf7f1 0%, var(--bg) 100%);
27
+ }
28
+ .wrap { max-width: 1120px; margin: 0 auto; padding: 32px 20px 56px; }
29
+ .hero { margin-bottom: 22px; }
30
+ h1 { margin: 0 0 10px; font-size: 34px; letter-spacing: -0.02em; }
31
+ .subtitle { margin: 0; max-width: 880px; color: var(--muted); line-height: 1.6; }
32
+ .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
33
+ .card {
34
+ border: 1px solid var(--panel-border);
35
+ border-radius: 18px;
36
+ padding: 18px;
37
+ margin-top: 16px;
38
+ background: var(--panel);
39
+ box-shadow: var(--shadow);
40
+ backdrop-filter: blur(10px);
41
+ }
42
+ .card h2 { margin: 0 0 8px; font-size: 22px; }
43
+ .card p { margin-top: 0; }
44
+ label { display: block; font-size: 14px; margin-bottom: 6px; color: var(--muted); }
45
+ input, button { width: 100%; padding: 10px 12px; box-sizing: border-box; }
46
+ input { border: 1px solid #cdd5df; border-radius: 10px; background: #fff; }
47
+ button { background: linear-gradient(135deg, var(--accent), #3b82f6); color: #fff; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; }
48
+ button:hover { background: linear-gradient(135deg, var(--accent-strong), #1d4ed8); }
49
+ .note { font-size: 13px; color: var(--muted); line-height: 1.6; }
50
+ .section-title { margin: 0 0 10px; font-size: 17px; }
51
+ .stack { display: grid; gap: 12px; }
52
+ .footer-note { margin-top: 18px; font-size: 13px; color: var(--muted); }
53
+ .live-status {
54
+ display: none;
55
+ margin-top: 22px;
56
+ }
57
+ .live-status.visible { display: block; }
58
+ .live-status iframe {
59
+ width: 100%;
60
+ min-height: 980px;
61
+ border: 0;
62
+ border-radius: 18px;
63
+ background: #fff;
64
+ box-shadow: var(--shadow);
65
+ }
66
+ .live-status .status-head {
67
+ display: flex;
68
+ justify-content: space-between;
69
+ align-items: baseline;
70
+ gap: 12px;
71
+ margin-bottom: 10px;
72
+ padding: 0 2px;
73
+ }
74
+ .live-status .status-title {
75
+ font-size: 20px;
76
+ margin: 0;
77
+ }
78
+ .live-status .status-note {
79
+ color: var(--muted);
80
+ font-size: 13px;
81
+ }
82
+ .file-field { display: grid; gap: 8px; }
83
+ .file-control {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ width: 100%;
88
+ min-width: 0;
89
+ padding: 10px 12px;
90
+ border: 1px solid #cdd5df;
91
+ border-radius: 10px;
92
+ background: #fff;
93
+ box-sizing: border-box;
94
+ overflow: hidden;
95
+ }
96
+ .file-control button {
97
+ width: 110px;
98
+ flex: 0 0 110px;
99
+ white-space: nowrap;
100
+ padding: 8px 12px;
101
+ border-radius: 8px;
102
+ }
103
+ .file-name {
104
+ color: var(--muted);
105
+ font-size: 13px;
106
+ overflow: hidden;
107
+ text-overflow: ellipsis;
108
+ white-space: nowrap;
109
+ min-width: 0;
110
+ flex: 1 1 auto;
111
+ }
112
+ .file-control input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
113
+ .result-panel {
114
+ display: none;
115
+ margin-top: 22px;
116
+ }
117
+ .result-panel.visible { display: block; }
118
+ .result-panel .result-head {
119
+ display: flex;
120
+ justify-content: space-between;
121
+ align-items: baseline;
122
+ gap: 12px;
123
+ margin-bottom: 10px;
124
+ padding: 0 2px;
125
+ }
126
+ .result-panel .result-title {
127
+ font-size: 20px;
128
+ margin: 0;
129
+ }
130
+ .result-panel .result-note {
131
+ color: var(--muted);
132
+ font-size: 13px;
133
+ }
134
+ .topline {
135
+ display: flex;
136
+ justify-content: space-between;
137
+ gap: 16px;
138
+ flex-wrap: wrap;
139
+ align-items: center;
140
+ margin-bottom: 16px;
141
+ }
142
+ .pill {
143
+ display: inline-flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ border-radius: 999px;
147
+ padding: 8px 12px;
148
+ background: rgba(37, 99, 168, 0.1);
149
+ color: var(--accent);
150
+ font-weight: 700;
151
+ }
152
+ .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
153
+ .stat { padding: 12px 14px; border-radius: 14px; background: rgba(37, 99, 168, 0.06); border: 1px solid rgba(37, 99, 168, 0.12); }
154
+ .stat .k { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
155
+ .stat .v { font-size: 18px; font-weight: 700; }
156
+ .meta { color: var(--muted); font-size: 14px; line-height: 1.6; }
157
+ table { width: 100%; border-collapse: collapse; overflow: hidden; }
158
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.08); font-size: 14px; }
159
+ th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
160
+ pre { white-space: pre-wrap; word-wrap: break-word; background: #f9fafb; padding: 14px; border-radius: 12px; overflow-x: auto; }
161
+ .preview-img {
162
+ width: 100%;
163
+ height: auto;
164
+ border-radius: 12px;
165
+ border: 1px solid rgba(0,0,0,0.08);
166
+ background: white;
167
+ }
168
+ .top5-grid { display: grid; gap: 8px; }
169
+ .top5-item {
170
+ padding: 10px 12px;
171
+ border-radius: 12px;
172
+ background: rgba(37, 99, 168, 0.06);
173
+ border: 1px solid rgba(37, 99, 168, 0.12);
174
+ font-size: 13px;
175
+ line-height: 1.6;
176
+ }
177
+ .helper-note {
178
+ color: var(--muted);
179
+ font-size: 13px;
180
+ line-height: 1.6;
181
+ margin-top: 8px;
182
+ }
183
+ </style>
184
+ </head>
185
+ <body>
186
+ <div class="wrap">
187
+ <div class="hero">
188
+ <h1>GEMS Raman Webserver</h1>
189
+ <p class="subtitle">Accelerate your spectral analysis with the GEMS foundation model. Please choose your desired workflow below:</p>
190
+
191
+ <ul class="workflow-instructions" style="list-style-type: none; padding-left: 0; margin-top: 20px;">
192
+ <li style="margin-bottom: 12px; line-height: 1.6;">
193
+ <strong style="color: #2563a8;">Fine-tune (Left Panel):</strong> Upload your raw spectral data and labels. The system will automatically execute a complete AutoML pipeline—including range cropping (0-3500 cm⁻¹), missing value interpolation, min-max normalization, dataset splitting, and minority class data augmentation—before fine-tuning the pretrained GEMS model and generating comprehensive evaluation reports.
194
+ </li>
195
+ <li style="line-height: 1.6;">
196
+ <strong style="color: #2563a8;">Predict (Right Panel):</strong> Bypass the training phase. Upload a previously fine-tuned <code style="background: #E2E8F0; padding: 2px 6px; border-radius: 4px; font-family: monospace;">.pth</code> model weight file alongside your new spectral data for instant classification and confident Top-5 logit predictions.
197
+ </li>
198
+ </ul>
199
+ </div>
200
+
201
+ <div class="grid">
202
+ <form class="card stack" action="/start" method="post" enctype="multipart/form-data">
203
+ <div>
204
+ <h2>Fine-tune</h2>
205
+ <p class="note">Upload the raw training data and a pretrained model to generate a new classifier checkpoint and test results.</p>
206
+ </div>
207
+
208
+ <div class="file-field">
209
+ <label>True Label Mapping File (optional, .json/.txt)</label>
210
+ <div class="file-control">
211
+ <button type="button" data-file-target="train_label_mapping_file">Choose file</button>
212
+ <span class="file-name" data-file-name="train_label_mapping_file">No file selected</span>
213
+ <input type="file" id="train_label_mapping_file" name="label_mapping_file" accept=".json,.txt">
214
+ </div>
215
+ </div>
216
+
217
+ <div class="grid">
218
+ <div class="file-field">
219
+ <label>Spectral (.npy)</label>
220
+ <div class="file-control">
221
+ <button type="button" data-file-target="spectral_file">Choose file</button>
222
+ <span class="file-name" data-file-name="spectral_file">No file selected</span>
223
+ <input type="file" id="spectral_file" name="spectral_file" accept=".npy" required>
224
+ </div>
225
+ </div>
226
+ <div class="file-field">
227
+ <label>Labels (.npy)</label>
228
+ <div class="file-control">
229
+ <button type="button" data-file-target="labels_file">Choose file</button>
230
+ <span class="file-name" data-file-name="labels_file">No file selected</span>
231
+ <input type="file" id="labels_file" name="labels_file" accept=".npy" required>
232
+ </div>
233
+ </div>
234
+ <div class="file-field">
235
+ <label>Wavenumbers (.npy)</label>
236
+ <div class="file-control">
237
+ <button type="button" data-file-target="wavenumbers_file">Choose file</button>
238
+ <span class="file-name" data-file-name="wavenumbers_file">No file selected</span>
239
+ <input type="file" id="wavenumbers_file" name="wavenumbers_file" accept=".npy" required>
240
+ </div>
241
+ </div>
242
+ <div class="file-field">
243
+ <label>Pretrained Model (.pth)</label>
244
+ <div class="file-control">
245
+ <button type="button" data-file-target="model_file">Choose file</button>
246
+ <span class="file-name" data-file-name="model_file">No file selected</span>
247
+ <input type="file" id="model_file" name="model_file" accept=".pth" required>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ <div class="grid">
253
+ <div><label>Epochs</label><input type="number" name="epochs" value="60"></div>
254
+ <div><label>Batch Size</label><input type="number" name="batch_size" value="64"></div>
255
+ <div><label>Learning Rate</label><input type="number" step="0.000001" name="lr" value="0.0001"></div>
256
+ <div><label>Weight Decay</label><input type="number" step="0.0001" name="weight_decay" value="0.001"></div>
257
+ <div><label>Patience</label><input type="number" name="patience" value="12"></div>
258
+ <div><label>Label Smoothing</label><input type="number" step="0.01" name="label_smoothing" value="0.0"></div>
259
+ </div>
260
+
261
+ <div>
262
+ <button type="submit">Start Fine-Tuning Job</button>
263
+ </div>
264
+ </form>
265
+
266
+ <form class="card stack" action="/predict" method="post" enctype="multipart/form-data">
267
+ <div>
268
+ <h2>Predict</h2>
269
+ <p class="note">Upload the fine-tuned classifier checkpoint exported after training, such as final_model.pth. Then upload the spectrum file you want to classify. If the spectrum file already includes wavenumbers, you can leave the optional wavelength file empty.</p>
270
+ </div>
271
+
272
+ <div class="stack">
273
+ <div class="file-field">
274
+ <label>True Label Mapping File (optional, .json/.txt)</label>
275
+ <div class="file-control">
276
+ <button type="button" data-file-target="predict_label_mapping_file">Choose file</button>
277
+ <span class="file-name" data-file-name="predict_label_mapping_file">No file selected</span>
278
+ <input type="file" id="predict_label_mapping_file" name="label_mapping_file" accept=".json,.txt">
279
+ </div>
280
+ </div>
281
+ <div class="file-field">
282
+ <label>Saved Model (.pth)</label>
283
+ <div class="file-control">
284
+ <button type="button" data-file-target="predict_model_file">Choose file</button>
285
+ <span class="file-name" data-file-name="predict_model_file">No file selected</span>
286
+ <input type="file" id="predict_model_file" name="model_file" accept=".pth" required>
287
+ </div>
288
+ </div>
289
+ <div class="file-field">
290
+ <label>Spectral (.npy/.txt/.csv)</label>
291
+ <div class="file-control">
292
+ <button type="button" data-file-target="predict_spectral_file">Choose file</button>
293
+ <span class="file-name" data-file-name="predict_spectral_file">No file selected</span>
294
+ <input type="file" id="predict_spectral_file" name="spectral_file" accept=".npy,.txt,.csv" required>
295
+ </div>
296
+ </div>
297
+ <div class="file-field">
298
+ <label>Wavelengths / Wavenumbers (optional, .npy/.txt/.csv)</label>
299
+ <div class="file-control">
300
+ <button type="button" data-file-target="predict_wavenumbers_file">Choose file</button>
301
+ <span class="file-name" data-file-name="predict_wavenumbers_file">No file selected</span>
302
+ <input type="file" id="predict_wavenumbers_file" name="wavenumbers_file" accept=".npy,.txt,.csv">
303
+ </div>
304
+ </div>
305
+ <div class="file-field">
306
+ <label>Manual wavelength range fallback (optional)</label>
307
+ <div class="grid" style="grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
308
+ <div>
309
+ <label>Low value</label>
310
+ <input type="number" step="0.0001" name="manual_low_cm" placeholder="e.g. 0">
311
+ </div>
312
+ <div>
313
+ <label>High value</label>
314
+ <input type="number" step="0.0001" name="manual_high_cm" placeholder="e.g. 3500">
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+
320
+ <div>
321
+ <button type="submit">Run Prediction</button>
322
+ </div>
323
+ </form>
324
+ </div>
325
+
326
+ <div class="live-status" id="live-status">
327
+ <div class="status-head">
328
+ <h2 class="status-title">Live Training Status</h2>
329
+ <div class="status-note">The results panel will appear here after you start a job.</div>
330
+ </div>
331
+ <iframe id="status-frame" title="Training status"></iframe>
332
+ </div>
333
+
334
+ <div class="result-panel" id="predict-results">
335
+ <div class="result-head">
336
+ <h2 class="result-title">Prediction Results</h2>
337
+ <div class="result-note">The results will appear here after you run prediction.</div>
338
+ </div>
339
+ <div id="predict-results-body"></div>
340
+ </div>
341
+
342
+ <div class="footer-note">After training finishes, the status page will show t-SNE, the confusion matrix, the classification report, and download links.</div>
343
+ </div>
344
+
345
+ <script>
346
+ const liveStatus = document.getElementById('live-status');
347
+ const statusFrame = document.getElementById('status-frame');
348
+ const predictResults = document.getElementById('predict-results');
349
+ const predictResultsBody = document.getElementById('predict-results-body');
350
+
351
+ document.querySelectorAll('[data-file-target]').forEach((button) => {
352
+ button.addEventListener('click', () => {
353
+ const targetId = button.getAttribute('data-file-target');
354
+ const input = document.getElementById(targetId);
355
+ if (input) {
356
+ input.click();
357
+ }
358
+ });
359
+ });
360
+
361
+ document.querySelectorAll('input[type="file"]').forEach((input) => {
362
+ input.addEventListener('change', () => {
363
+ const nameNode = document.querySelector(`[data-file-name="${input.id}"]`);
364
+ if (nameNode) {
365
+ const fileName = input.files.length ? input.files[0].name : 'No file selected';
366
+ nameNode.textContent = fileName;
367
+ nameNode.title = fileName;
368
+ }
369
+ });
370
+ });
371
+
372
+ const syncHoverText = () => {
373
+ document.querySelectorAll('input:not([type="file"]):not([type="hidden"])').forEach((input) => {
374
+ const hoverText = input.value || input.placeholder || input.getAttribute('aria-label') || input.name || '';
375
+ input.title = hoverText;
376
+ });
377
+
378
+ document.querySelectorAll('.file-name').forEach((node) => {
379
+ node.title = node.textContent.trim();
380
+ });
381
+ };
382
+
383
+ document.querySelectorAll('input:not([type="file"]):not([type="hidden"])').forEach((input) => {
384
+ input.addEventListener('input', syncHoverText);
385
+ input.addEventListener('change', syncHoverText);
386
+ });
387
+
388
+ syncHoverText();
389
+
390
+ document.querySelectorAll('form[action="/start"]').forEach((form) => {
391
+ form.addEventListener('submit', async (event) => {
392
+ if (!form.checkValidity()) {
393
+ form.reportValidity();
394
+ return;
395
+ }
396
+ event.preventDefault();
397
+ const submitButton = form.querySelector('button[type="submit"]');
398
+ const originalLabel = submitButton ? submitButton.textContent : '';
399
+ if (submitButton) {
400
+ submitButton.disabled = true;
401
+ submitButton.textContent = 'Starting...';
402
+ }
403
+
404
+ try {
405
+ const response = await fetch(form.action, {
406
+ method: 'POST',
407
+ headers: {
408
+ 'Accept': 'application/json',
409
+ 'X-Requested-With': 'XMLHttpRequest'
410
+ },
411
+ body: new FormData(form),
412
+ credentials: 'same-origin'
413
+ });
414
+ if (!response.ok) {
415
+ throw new Error(`HTTP ${response.status}`);
416
+ }
417
+ const payload = await response.json();
418
+ const targetUrl = payload.status_url;
419
+ if (statusFrame) {
420
+ statusFrame.src = targetUrl;
421
+ }
422
+ if (liveStatus) {
423
+ liveStatus.classList.add('visible');
424
+ liveStatus.scrollIntoView({ behavior: 'smooth', block: 'start' });
425
+ }
426
+ } catch (error) {
427
+ alert(`Failed to start the training job: ${error}`);
428
+ } finally {
429
+ if (submitButton) {
430
+ submitButton.disabled = false;
431
+ submitButton.textContent = originalLabel;
432
+ }
433
+ }
434
+ });
435
+ });
436
+
437
+ document.querySelectorAll('form[action="/predict"]').forEach((form) => {
438
+ form.addEventListener('submit', async (event) => {
439
+ if (!form.checkValidity()) {
440
+ form.reportValidity();
441
+ return;
442
+ }
443
+ event.preventDefault();
444
+ const submitButton = form.querySelector('button[type="submit"]');
445
+ const originalLabel = submitButton ? submitButton.textContent : '';
446
+ if (submitButton) {
447
+ submitButton.disabled = true;
448
+ submitButton.textContent = 'Running...';
449
+ }
450
+
451
+ try {
452
+ const response = await fetch(form.action, {
453
+ method: 'POST',
454
+ headers: {
455
+ 'Accept': 'application/json',
456
+ 'X-Requested-With': 'XMLHttpRequest'
457
+ },
458
+ body: new FormData(form),
459
+ credentials: 'same-origin'
460
+ });
461
+
462
+ const payload = await response.json().catch(() => null);
463
+ if (!response.ok) {
464
+ const message = payload && payload.detail ? payload.detail : `HTTP ${response.status}`;
465
+ throw new Error(message);
466
+ }
467
+
468
+ if (predictResultsBody && payload && payload.results_html) {
469
+ predictResultsBody.innerHTML = payload.results_html;
470
+ }
471
+ if (predictResults) {
472
+ predictResults.classList.add('visible');
473
+ predictResults.scrollIntoView({ behavior: 'smooth', block: 'start' });
474
+ }
475
+ } catch (error) {
476
+ alert(`Failed to run prediction: ${error.message || error}`);
477
+ } finally {
478
+ if (submitButton) {
479
+ submitButton.disabled = false;
480
+ submitButton.textContent = originalLabel;
481
+ }
482
+ }
483
+ });
484
+ });
485
+ </script>
486
+ </body>
487
+ </html>
webserver/templates/predict.html ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Prediction Results</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f4f0e8;
11
+ --panel: rgba(255, 255, 255, 0.9);
12
+ --panel-border: rgba(33, 37, 41, 0.12);
13
+ --text: #1f2937;
14
+ --muted: #5b6472;
15
+ --accent: #2563a8;
16
+ --shadow: 0 18px 50px rgba(32, 41, 48, 0.12);
17
+ }
18
+ body {
19
+ margin: 0;
20
+ font-family: "Segoe UI", Tahoma, sans-serif;
21
+ color: var(--text);
22
+ background:
23
+ radial-gradient(circle at top left, rgba(37, 99, 168, 0.14), transparent 28%),
24
+ linear-gradient(180deg, #faf7f1 0%, var(--bg) 100%);
25
+ }
26
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 32px 20px 56px; }
27
+ .card { border: 1px solid var(--panel-border); border-radius: 18px; padding: 18px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(10px); margin-top: 16px; }
28
+ .topline { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
29
+ .pill { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 8px 12px; background: rgba(37, 99, 168, 0.1); color: var(--accent); font-weight: 700; }
30
+ .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
31
+ .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
32
+ .stat { padding: 12px 14px; border-radius: 14px; background: rgba(37, 99, 168, 0.06); border: 1px solid rgba(37, 99, 168, 0.12); }
33
+ .stat .k { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
34
+ .stat .v { font-size: 18px; font-weight: 700; }
35
+ .meta { color: var(--muted); font-size: 14px; line-height: 1.6; }
36
+ table { width: 100%; border-collapse: collapse; overflow: hidden; }
37
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.08); font-size: 14px; }
38
+ th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
39
+ a { color: var(--accent); text-decoration: none; }
40
+ a:hover { text-decoration: underline; }
41
+ pre { white-space: pre-wrap; word-wrap: break-word; background: #f9fafb; padding: 14px; border-radius: 12px; overflow-x: auto; }
42
+ .file-field { display: grid; gap: 8px; }
43
+ .file-control {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 10px;
47
+ width: 100%;
48
+ min-width: 0;
49
+ padding: 10px 12px;
50
+ border: 1px solid #cdd5df;
51
+ border-radius: 10px;
52
+ background: #fff;
53
+ box-sizing: border-box;
54
+ overflow: hidden;
55
+ }
56
+ .file-control button {
57
+ width: 110px;
58
+ flex: 0 0 110px;
59
+ white-space: nowrap;
60
+ padding: 8px 12px;
61
+ border-radius: 8px;
62
+ }
63
+ .file-name {
64
+ color: var(--muted);
65
+ font-size: 13px;
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ min-width: 0;
70
+ flex: 1 1 auto;
71
+ }
72
+ .file-control input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
73
+ .preview-img {
74
+ width: 100%;
75
+ height: auto;
76
+ border-radius: 12px;
77
+ border: 1px solid rgba(0,0,0,0.08);
78
+ background: white;
79
+ }
80
+ .top5-grid {
81
+ display: grid;
82
+ gap: 8px;
83
+ }
84
+ .top5-item {
85
+ padding: 10px 12px;
86
+ border-radius: 12px;
87
+ background: rgba(37, 99, 168, 0.06);
88
+ border: 1px solid rgba(37, 99, 168, 0.12);
89
+ font-size: 13px;
90
+ line-height: 1.6;
91
+ }
92
+ .helper-note {
93
+ color: var(--muted);
94
+ font-size: 13px;
95
+ line-height: 1.6;
96
+ margin-top: 8px;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div class="wrap">
102
+ <div class="topline">
103
+ <div>
104
+ <h2>Prediction Results: {{ prediction_id }}</h2>
105
+ <div class="meta">The model checkpoint has been loaded and predictions have been generated.</div>
106
+ </div>
107
+ <div class="pill">DONE</div>
108
+ </div>
109
+
110
+ <div class="card">
111
+ <form action="/predict" method="post" enctype="multipart/form-data" class="grid" style="margin-bottom: 16px;">
112
+ <div class="file-field">
113
+ <label>Saved Model (.pth)</label>
114
+ <div class="file-control">
115
+ <button type="button" data-file-target="predict_model_file">Choose file</button>
116
+ <span class="file-name" data-file-name="predict_model_file">No file selected</span>
117
+ <input type="file" id="predict_model_file" name="model_file" accept=".pth" required>
118
+ </div>
119
+ </div>
120
+ <div class="file-field">
121
+ <label>True Label Mapping File (optional, .json/.txt)</label>
122
+ <div class="file-control">
123
+ <button type="button" data-file-target="predict_label_mapping_file">Choose file</button>
124
+ <span class="file-name" data-file-name="predict_label_mapping_file">No file selected</span>
125
+ <input type="file" id="predict_label_mapping_file" name="label_mapping_file" accept=".json,.txt">
126
+ </div>
127
+ </div>
128
+ <div class="file-field">
129
+ <label>Spectral (.npy/.txt/.csv)</label>
130
+ <div class="file-control">
131
+ <button type="button" data-file-target="predict_spectral_file">Choose file</button>
132
+ <span class="file-name" data-file-name="predict_spectral_file">No file selected</span>
133
+ <input type="file" id="predict_spectral_file" name="spectral_file" accept=".npy,.txt,.csv" required>
134
+ </div>
135
+ </div>
136
+ <div class="file-field">
137
+ <label>Wavelengths / Wavenumbers (optional, .npy/.txt)</label>
138
+ <div class="file-control">
139
+ <button type="button" data-file-target="predict_wavenumbers_file">Choose file</button>
140
+ <span class="file-name" data-file-name="predict_wavenumbers_file">No file selected</span>
141
+ <input type="file" id="predict_wavenumbers_file" name="wavenumbers_file" accept=".npy,.txt,.csv">
142
+ </div>
143
+ </div>
144
+ <div class="file-field">
145
+ <label>Manual wavelength range fallback (optional)</label>
146
+ <div class="grid" style="grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
147
+ <div>
148
+ <label style="margin-bottom: 4px;">Low value</label>
149
+ <input type="number" step="0.0001" name="manual_low_cm" placeholder="e.g. 0">
150
+ </div>
151
+ <div>
152
+ <label style="margin-bottom: 4px;">High value</label>
153
+ <input type="number" step="0.0001" name="manual_high_cm" placeholder="e.g. 3500">
154
+ </div>
155
+ </div>
156
+ </div>
157
+ <div style="grid-column: 1 / -1;">
158
+ <button type="submit" style="width: 100%;">Run Prediction</button>
159
+ </div>
160
+ </form>
161
+
162
+ <div class="helper-note">
163
+ If your spectrum file already contains two columns (wavelength and intensity), you can leave the wavelength file empty.
164
+ If the spectrum file contains only intensity values, upload a wavelength file or fill in the manual range.
165
+ </div>
166
+
167
+ <div class="stats">
168
+ <div class="stat"><div class="k">Samples</div><div class="v">{{ summary.num_samples }}</div></div>
169
+ <div class="stat"><div class="k">Classes</div><div class="v">{{ summary.class_names|length }}</div></div>
170
+ <div class="stat"><div class="k">Download CSV</div><div class="v"><a href="{{ download_csv }}">CSV</a></div></div>
171
+ </div>
172
+ <div class="meta" style="margin-top: 12px;">If you need the same checkpoint for another dataset, keep the exported pth file and upload it here again.</div>
173
+ </div>
174
+
175
+ <div class="grid">
176
+ <div class="card">
177
+ <h3>Per-sample Predictions</h3>
178
+ <table>
179
+ <thead>
180
+ <tr>
181
+ <th>Sample</th>
182
+ <th>Predicted class</th>
183
+ <th>Confidence</th>
184
+ </tr>
185
+ </thead>
186
+ <tbody>
187
+ {% for row in rows %}
188
+ <tr>
189
+ <td>{{ row.sample_index }}</td>
190
+ <td>{{ row.pred_label }}</td>
191
+ <td>{{ "%.4f"|format(row.confidence) }}</td>
192
+ </tr>
193
+ {% endfor %}
194
+ </tbody>
195
+ </table>
196
+ </div>
197
+
198
+ <div class="card">
199
+ <h3>Top-5 Logits</h3>
200
+ <div class="top5-grid">
201
+ {% for row in top5_rows %}
202
+ <div class="top5-item">
203
+ <strong>Sample {{ row.sample_index }}</strong>
204
+ <div>
205
+ {% for item in row.top5 %}
206
+ <div>Top{{ item.rank }}: {{ item.class_name }} - {{ "%.6f"|format(item.logit) }}</div>
207
+ {% endfor %}
208
+ </div>
209
+ </div>
210
+ {% endfor %}
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <div class="grid">
216
+ <div class="card">
217
+ <h3>Input Spectra Preview</h3>
218
+ <img class="preview-img" src="{{ preview_image }}" alt="Input spectra preview">
219
+ </div>
220
+ </div>
221
+
222
+ <div class="card">
223
+ <h3>Model Notes</h3>
224
+ <div class="meta">The model checkpoint was loaded with its saved architecture and preprocessing config, so the same pth file can be reused later for inference on new spectra.</div>
225
+ {% if summary.spectrum_source or summary.wavenumber_source %}
226
+ <div class="helper-note">Spectrum source: {{ summary.spectrum_source }} | Wavelength source: {{ summary.wavenumber_source }}</div>
227
+ {% endif %}
228
+ {% if summary.label_mapping_source %}
229
+ <div class="helper-note">Label mapping source: {{ summary.label_mapping_source }}</div>
230
+ {% endif %}
231
+ </div>
232
+
233
+ <p style="margin-top: 12px;"><a href="/">Back</a></p>
234
+ </div>
235
+
236
+ <script>
237
+ document.querySelectorAll('[data-file-target]').forEach((button) => {
238
+ button.addEventListener('click', () => {
239
+ const targetId = button.getAttribute('data-file-target');
240
+ const input = document.getElementById(targetId);
241
+ if (input) {
242
+ input.click();
243
+ }
244
+ });
245
+ });
246
+
247
+ document.querySelectorAll('input[type="file"]').forEach((input) => {
248
+ input.addEventListener('change', () => {
249
+ const nameNode = document.querySelector(`[data-file-name="${input.id}"]`);
250
+ if (nameNode) {
251
+ const fileName = input.files.length ? input.files[0].name : 'No file selected';
252
+ nameNode.textContent = fileName;
253
+ nameNode.title = fileName;
254
+ }
255
+ });
256
+ });
257
+
258
+ const syncHoverText = () => {
259
+ document.querySelectorAll('input:not([type="file"]):not([type="hidden"])').forEach((input) => {
260
+ const hoverText = input.value || input.placeholder || input.getAttribute('aria-label') || input.name || '';
261
+ input.title = hoverText;
262
+ });
263
+
264
+ document.querySelectorAll('.file-name').forEach((node) => {
265
+ node.title = node.textContent.trim();
266
+ });
267
+ };
268
+
269
+ document.querySelectorAll('input:not([type="file"]):not([type="hidden"])').forEach((input) => {
270
+ input.addEventListener('input', syncHoverText);
271
+ input.addEventListener('change', syncHoverText);
272
+ });
273
+
274
+ syncHoverText();
275
+ </script>
276
+ </body>
277
+ </html>
webserver/templates/predict_result_fragment.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="card">
2
+ <div class="topline">
3
+ <div>
4
+ <h2>Prediction Results: {{ prediction_id }}</h2>
5
+ <div class="meta">The model checkpoint has been loaded and predictions have been generated.</div>
6
+ </div>
7
+ <div class="pill">DONE</div>
8
+ </div>
9
+
10
+ <div class="stats">
11
+ <div class="stat"><div class="k">Samples</div><div class="v">{{ summary.num_samples }}</div></div>
12
+ <div class="stat"><div class="k">Classes</div><div class="v">{{ summary.class_names|length }}</div></div>
13
+ <div class="stat"><div class="k">Download CSV</div><div class="v"><a href="{{ download_csv }}">CSV</a></div></div>
14
+ </div>
15
+ <div class="meta" style="margin-top: 12px;">If you need the same checkpoint for another dataset, keep the exported pth file and upload it here again.</div>
16
+ {% if summary.spectrum_source or summary.wavenumber_source %}
17
+ <div class="helper-note">Spectrum source: {{ summary.spectrum_source }} | Wavelength source: {{ summary.wavenumber_source }}</div>
18
+ {% endif %}
19
+ {% if summary.label_mapping_source %}
20
+ <div class="helper-note">Label mapping source: {{ summary.label_mapping_source }}</div>
21
+ {% endif %}
22
+ </div>
23
+
24
+ <div class="grid">
25
+ <div class="card">
26
+ <h3>Per-sample Predictions</h3>
27
+ <table>
28
+ <thead>
29
+ <tr>
30
+ <th>Sample</th>
31
+ <th>Predicted class</th>
32
+ <th>Confidence</th>
33
+ </tr>
34
+ </thead>
35
+ <tbody>
36
+ {% for row in rows %}
37
+ <tr>
38
+ <td>{{ row.sample_index }}</td>
39
+ <td>{{ row.pred_label }}</td>
40
+ <td>{{ "%.4f"|format(row.confidence) }}</td>
41
+ </tr>
42
+ {% endfor %}
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+
47
+ <div class="card">
48
+ <h3>Top-5 Logits</h3>
49
+ <div class="top5-grid">
50
+ {% for row in top5_rows %}
51
+ <div class="top5-item">
52
+ <strong>Sample {{ row.sample_index }}</strong>
53
+ <div>
54
+ {% for item in row.top5 %}
55
+ <div>Top{{ item.rank }}: {{ item.class_name }} - {{ "%.6f"|format(item.logit) }}</div>
56
+ {% endfor %}
57
+ </div>
58
+ </div>
59
+ {% endfor %}
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div class="grid">
65
+ <div class="card">
66
+ <h3>Input Spectra Preview</h3>
67
+ <img class="preview-img" src="{{ preview_image }}" alt="Input spectra preview">
68
+ </div>
69
+ </div>
webserver/templates/status.html ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Job Status</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f4f0e8;
11
+ --panel: rgba(255, 255, 255, 0.9);
12
+ --panel-border: rgba(33, 37, 41, 0.12);
13
+ --text: #1f2937;
14
+ --muted: #5b6472;
15
+ --accent: #2563a8;
16
+ --shadow: 0 18px 50px rgba(32, 41, 48, 0.12);
17
+ }
18
+ body {
19
+ margin: 0;
20
+ font-family: "Segoe UI", Tahoma, sans-serif;
21
+ color: var(--text);
22
+ background:
23
+ radial-gradient(circle at top left, rgba(37, 99, 168, 0.14), transparent 28%),
24
+ linear-gradient(180deg, #faf7f1 0%, var(--bg) 100%);
25
+ }
26
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 32px 20px 56px; }
27
+ h2, h3 { margin-top: 0; }
28
+ .topline { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
29
+ .pill { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 8px 12px; background: rgba(37, 99, 168, 0.1); color: var(--accent); font-weight: 700; }
30
+ .card { border: 1px solid var(--panel-border); border-radius: 18px; padding: 18px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(10px); }
31
+ .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
32
+ .stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
33
+ .stat { padding: 12px 14px; border-radius: 14px; background: rgba(37, 99, 168, 0.06); border: 1px solid rgba(37, 99, 168, 0.12); }
34
+ .stat .k { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
35
+ .stat .v { font-size: 18px; font-weight: 700; }
36
+ pre { white-space: pre-wrap; word-wrap: break-word; background: #f9fafb; padding: 14px; border-radius: 12px; overflow-x: auto; }
37
+ a { color: var(--accent); text-decoration: none; }
38
+ a:hover { text-decoration: underline; }
39
+ .artifact { display: grid; gap: 10px; }
40
+ .artifact img { width: 100%; height: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.08); background: white; }
41
+ .meta { color: var(--muted); font-size: 14px; line-height: 1.6; }
42
+ .progress-shell {
43
+ margin: 14px 0 10px;
44
+ background: rgba(37, 99, 168, 0.10);
45
+ border: 1px solid rgba(37, 99, 168, 0.14);
46
+ border-radius: 999px;
47
+ height: 16px;
48
+ overflow: hidden;
49
+ }
50
+ .progress-bar {
51
+ height: 100%;
52
+ width: 0%;
53
+ border-radius: inherit;
54
+ background: linear-gradient(90deg, #2563a8, #3b82f6);
55
+ transition: width 0.35s ease;
56
+ }
57
+ .progress-row {
58
+ display: flex;
59
+ justify-content: space-between;
60
+ align-items: center;
61
+ gap: 12px;
62
+ margin-top: 8px;
63
+ font-size: 13px;
64
+ color: var(--muted);
65
+ }
66
+ .device-banner {
67
+ display: flex;
68
+ justify-content: space-between;
69
+ align-items: center;
70
+ gap: 12px;
71
+ margin-bottom: 10px;
72
+ padding: 12px 14px;
73
+ border-radius: 14px;
74
+ background: rgba(37, 99, 168, 0.06);
75
+ border: 1px solid rgba(37, 99, 168, 0.12);
76
+ }
77
+ .device-banner .device-value {
78
+ font-weight: 800;
79
+ color: var(--accent);
80
+ }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="wrap">
85
+ <div class="topline">
86
+ <div>
87
+ <h2>Job Status: {{ job_id }}</h2>
88
+ <div class="meta">Updated at {{ job.updated_at }}</div>
89
+ </div>
90
+ <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end;">
91
+ {% if can_stop %}
92
+ <button id="stop-job-btn" type="button" style="padding:10px 14px; border:none; border-radius:999px; background:#b94b4b; color:white; font-weight:700; cursor:pointer; box-shadow: var(--shadow);">Stop Job</button>
93
+ {% endif %}
94
+ <div class="pill">{{ job.status|upper }}</div>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="card" style="margin-bottom: 16px;">
99
+ <div class="device-banner">
100
+ <div>
101
+ <strong>Current Device</strong>
102
+ <div class="meta">{{ job.device_name if job.device_name else 'Detecting device...' }}</div>
103
+ </div>
104
+ <div class="device-value">{{ job.device_label if job.device_label else 'Detecting...' }}{% if job.device_backend %} ({{ job.device_backend }}){% endif %}</div>
105
+ </div>
106
+ <div class="meta"><strong>Message:</strong> {{ job.message }}</div>
107
+ <div class="progress-shell" aria-label="Training progress">
108
+ <div class="progress-bar" style="width: {{ job.progress if job.progress is not none else 0 }}%;"></div>
109
+ </div>
110
+ <div class="progress-row">
111
+ <span>{{ job.phase|replace('_', ' ')|title if job.phase else 'Queued' }}</span>
112
+ <span>{{ job.progress if job.progress is not none else 0 }}%</span>
113
+ </div>
114
+ {% if summary.label_mapping_source %}
115
+ <div class="meta" style="margin-top: 10px;"><strong>Label Mapping:</strong> {{ summary.label_mapping_source }}</div>
116
+ {% endif %}
117
+ {% if job.get('current_epoch') is not none and job.get('total_epochs') is not none and job.get('total_epochs') > 0 %}
118
+ <div class="progress-row" style="margin-top: 4px;">
119
+ <span>Epoch {{ job.get('current_epoch', 0) }}/{{ job.get('total_epochs', 0) }}</span>
120
+ <span>{{ job.status|upper }}</span>
121
+ </div>
122
+ {% endif %}
123
+ </div>
124
+
125
+ <div class="grid">
126
+ <div class="card">
127
+ <h3>Training Visuals</h3>
128
+ <div class="artifact">
129
+ {% for item in visual_artifacts %}
130
+ <div>
131
+ <div class="meta"><strong>{{ item.key|replace('_', ' ')|title }}</strong> - <a href="{{ item.url }}">download</a></div>
132
+ <img src="{{ item.url }}" alt="{{ item.key }}">
133
+ </div>
134
+ {% endfor %}
135
+ </div>
136
+ </div>
137
+
138
+ <div class="card">
139
+ <h3>Classification Report</h3>
140
+ {% if report_text %}
141
+ <pre>{{ report_text }}</pre>
142
+ {% else %}
143
+ <div class="meta">The report has not been generated yet, or training is still in progress.</div>
144
+ {% endif %}
145
+ </div>
146
+ </div>
147
+
148
+ <div class="grid" style="margin-top: 16px;">
149
+ <div class="card">
150
+ <h3>Text and File Downloads</h3>
151
+ <div class="meta">
152
+ {% for item in download_artifacts %}
153
+ <div style="margin-bottom: 8px;">
154
+ <strong>{{ item.key|replace('_', ' ')|title }}</strong>:
155
+ <a href="{{ item.url }}">{{ item.filename }}</a>
156
+ </div>
157
+ {% endfor %}
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ {% if job.traceback %}
163
+ <div class="card" style="margin-top: 16px;">
164
+ <h3>Traceback</h3>
165
+ <pre>{{ job.traceback }}</pre>
166
+ </div>
167
+ {% endif %}
168
+ </div>
169
+
170
+ <script>
171
+ const stopButton = document.getElementById('stop-job-btn');
172
+ if (stopButton) {
173
+ stopButton.addEventListener('click', async () => {
174
+ const confirmed = window.confirm('Cancel the current training job? This will stop the process immediately.');
175
+ if (!confirmed) {
176
+ return;
177
+ }
178
+ stopButton.disabled = true;
179
+ stopButton.textContent = 'Stopping...';
180
+ try {
181
+ const response = await fetch('/stop/{{ job_id }}', {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Accept': 'application/json'
185
+ }
186
+ });
187
+ if (!response.ok) {
188
+ throw new Error(`HTTP ${response.status}`);
189
+ }
190
+ window.location.reload();
191
+ } catch (error) {
192
+ alert(`Failed to stop the job: ${error}`);
193
+ stopButton.disabled = false;
194
+ stopButton.textContent = 'Stop Job';
195
+ }
196
+ });
197
+ }
198
+
199
+ setTimeout(() => { window.location.reload(); }, 5000);
200
+ </script>
201
+ </body>
202
+ </html>
webserver/train_service.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import traceback
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ import numpy as np
8
+ import torch
9
+ from sklearn.metrics import accuracy_score, f1_score
10
+ from sklearn.preprocessing import LabelEncoder
11
+ from torch.utils.data import DataLoader
12
+
13
+ from webserver.label_utils import apply_label_mapping, load_label_mapping
14
+ from webserver.preprocess_utils import augment_small_trainset, preprocess_raman_dataset, preprocess_raman_spectra
15
+
16
+ # Make project root importable when the web server runs from ./webserver
17
+ ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
18
+ MAIN_DIR = os.path.join(ROOT_DIR, "main")
19
+ if ROOT_DIR not in os.sys.path:
20
+ os.sys.path.insert(0, ROOT_DIR)
21
+ if MAIN_DIR not in os.sys.path:
22
+ os.sys.path.insert(0, MAIN_DIR)
23
+
24
+ from main.Ramandataset import RamanDataset
25
+ from main.Raman_Task import (
26
+ load_mae_model_for_classification,
27
+ stratified_split_with_minimum_samples,
28
+ train_predictor,
29
+ RamanClassifier,
30
+ RamanEncoder,
31
+ )
32
+ from main.GEMS import MaskedAutoencoderRaman
33
+ from main.evaluate_visualize import visualize_model_performance
34
+
35
+
36
+ @dataclass
37
+ class TrainConfig:
38
+ epochs: int = 60
39
+ lr: float = 1e-4
40
+ weight_decay: float = 1e-3
41
+ patience: int = 12
42
+ batch_size: int = 64
43
+ patch_num: int = 100
44
+ embedding_dim: int = 512
45
+ num_layers: int = 12
46
+ num_heads: int = 16
47
+ freeze_encoder: bool = False
48
+ label_smoothing: float = 0.0
49
+
50
+
51
+ def _save_npy(path: str, arr) -> str:
52
+ np.save(path, arr)
53
+ return path
54
+
55
+
56
+ def _update_job(jobs: dict, job_id: str, **fields):
57
+ current = dict(jobs.get(job_id, {}))
58
+ current.update(fields)
59
+ current["updated_at"] = datetime.now().isoformat(timespec="seconds")
60
+ jobs[job_id] = current
61
+
62
+
63
+ def _resolve_device_info():
64
+ if torch.cuda.is_available():
65
+ backend = "ROCm" if getattr(torch.version, "hip", None) else "CUDA"
66
+ device_name = torch.cuda.get_device_name(0)
67
+ return torch.device("cuda"), {
68
+ "device_type": "gpu",
69
+ "device_label": "GPU",
70
+ "device_backend": backend,
71
+ "device_name": device_name,
72
+ }
73
+
74
+ return torch.device("cpu"), {
75
+ "device_type": "cpu",
76
+ "device_label": "CPU",
77
+ "device_backend": "CPU",
78
+ "device_name": "CPU",
79
+ }
80
+
81
+
82
+ def _training_progress_update(jobs: dict, job_id: str, stage: str, epoch: int, total_epochs: int, message: str):
83
+ total_epochs = max(int(total_epochs or 1), 1)
84
+ epoch = max(int(epoch or 0), 0)
85
+
86
+ if stage == "classification":
87
+ base = 30
88
+ span = 35
89
+ phase = "classification"
90
+ elif stage == "reconstruction":
91
+ base = 65
92
+ span = 25
93
+ phase = "reconstruction"
94
+ else:
95
+ base = 0
96
+ span = 100
97
+ phase = stage
98
+
99
+ progress = min(99, base + int((epoch / total_epochs) * span))
100
+ _update_job(
101
+ jobs,
102
+ job_id,
103
+ progress=progress,
104
+ phase=phase,
105
+ current_epoch=epoch,
106
+ total_epochs=total_epochs,
107
+ message=message,
108
+ status="running",
109
+ )
110
+
111
+
112
+ def _torch_load_safe(path: str, device):
113
+ try:
114
+ return torch.load(path, map_location=device, weights_only=False)
115
+ except TypeError:
116
+ return torch.load(path, map_location=device)
117
+
118
+
119
+ def load_classifier_checkpoint(checkpoint_path: str, device):
120
+ checkpoint = _torch_load_safe(checkpoint_path, device)
121
+ if isinstance(checkpoint, dict):
122
+ model_config = checkpoint.get("model_config", {})
123
+ class_names = [str(name) for name in checkpoint.get("class_names", [])]
124
+ label_mapping = checkpoint.get("label_mapping")
125
+ else:
126
+ model_config = {}
127
+ class_names = []
128
+ label_mapping = None
129
+
130
+ input_length = int(model_config.get("input_length", 3500))
131
+ patch_num = int(model_config.get("patch_num", 100))
132
+ embedding_dim = int(model_config.get("embedding_dim", 512))
133
+ num_layers = int(model_config.get("num_layers", 12))
134
+ num_heads = int(model_config.get("num_heads", 16))
135
+ num_classes = int(model_config.get("num_classes", max(len(class_names), 1)))
136
+
137
+ mae_model = MaskedAutoencoderRaman(
138
+ input_length=input_length,
139
+ patch_num=patch_num,
140
+ embed_dim=embedding_dim,
141
+ depth=num_layers,
142
+ num_heads=num_heads,
143
+ decoder_embed_dim=embedding_dim // 2,
144
+ decoder_depth=4,
145
+ decoder_num_heads=max(num_heads // 2, 1),
146
+ ).to(device)
147
+ encoder = RamanEncoder(mae_model).to(device)
148
+ classifier = RamanClassifier(encoder, num_classes).to(device)
149
+
150
+ if isinstance(checkpoint, dict):
151
+ state_dict = checkpoint.get("model_state_dict") or checkpoint.get("state_dict") or checkpoint
152
+ else:
153
+ state_dict = checkpoint
154
+ try:
155
+ classifier.load_state_dict(state_dict)
156
+ except RuntimeError as exc:
157
+ raise ValueError(
158
+ "The uploaded .pth file is not a fine-tuned prediction checkpoint. Please upload the exported final_model.pth or another classifier checkpoint saved after fine-tuning."
159
+ ) from exc
160
+ classifier.eval()
161
+
162
+ display_class_names = apply_label_mapping(class_names, label_mapping)
163
+
164
+ if isinstance(checkpoint, dict):
165
+ checkpoint = dict(checkpoint)
166
+ checkpoint["raw_class_names"] = class_names
167
+ checkpoint["class_names"] = display_class_names
168
+ checkpoint["label_mapping"] = label_mapping
169
+
170
+ return classifier, checkpoint
171
+
172
+
173
+ def predict_with_checkpoint(checkpoint_path: str, spectra: np.ndarray, wavenumbers: np.ndarray, device, display_label_mapping=None):
174
+ classifier, checkpoint = load_classifier_checkpoint(checkpoint_path, device)
175
+ model_config = checkpoint.get("model_config", {})
176
+ preprocess_config = checkpoint.get("preprocess_config", {})
177
+ raw_class_names = [str(name) for name in checkpoint.get("raw_class_names", checkpoint.get("class_names", []))]
178
+ checkpoint_label_mapping = checkpoint.get("label_mapping")
179
+ class_names = apply_label_mapping(raw_class_names, display_label_mapping or checkpoint.get("label_mapping"))
180
+
181
+ processed_x, target_w = preprocess_raman_spectra(
182
+ spectra,
183
+ wavenumbers,
184
+ target_len=int(preprocess_config.get("target_len", model_config.get("input_length", 3500))),
185
+ low_cm=float(preprocess_config.get("low_cm", 0.0)),
186
+ high_cm=float(preprocess_config.get("high_cm", 3500.0)),
187
+ eps_fill=float(preprocess_config.get("eps_fill", 1e-8)),
188
+ )
189
+
190
+ inputs = torch.from_numpy(processed_x).unsqueeze(1).to(device)
191
+
192
+ with torch.no_grad():
193
+ logits, embeddings = classifier(inputs)
194
+ probs = torch.softmax(logits, dim=1)
195
+ preds = torch.argmax(logits, dim=1)
196
+
197
+ if not class_names:
198
+ class_names = [str(idx) for idx in range(probs.shape[1])]
199
+
200
+ pred_indices = preds.cpu().numpy().tolist()
201
+ confidences = probs.max(dim=1).values.cpu().numpy().tolist()
202
+ pred_labels = [class_names[idx] if idx < len(class_names) else str(idx) for idx in pred_indices]
203
+
204
+ return {
205
+ "pred_indices": pred_indices,
206
+ "pred_labels": pred_labels,
207
+ "confidences": confidences,
208
+ "logits": logits.cpu().numpy(),
209
+ "probabilities": probs.cpu().numpy(),
210
+ "embeddings": embeddings.cpu().numpy(),
211
+ "class_names": class_names,
212
+ "raw_class_names": raw_class_names,
213
+ "checkpoint_label_mapping": checkpoint_label_mapping,
214
+ "target_wavenumbers": target_w,
215
+ "processed_spectra": processed_x,
216
+ "model_config": model_config,
217
+ "preprocess_config": preprocess_config,
218
+ }
219
+
220
+
221
+ def run_finetune_job(job_id: str, input_paths: dict, run_dir: str, config: TrainConfig, jobs: dict):
222
+ try:
223
+ _update_job(
224
+ jobs,
225
+ job_id,
226
+ status="running",
227
+ message="Loading dataset...",
228
+ progress=0,
229
+ phase="loading",
230
+ current_epoch=0,
231
+ total_epochs=config.epochs,
232
+ )
233
+
234
+ spectral = np.load(input_paths["spectral"], allow_pickle=True)
235
+ labels = np.load(input_paths["labels"], allow_pickle=True)
236
+ wavenumbers = np.load(input_paths["wavenumbers"], allow_pickle=True)
237
+
238
+ _update_job(jobs, job_id, message="Preprocessing (crop/pad/interpolate/normalize)...", progress=5, phase="preprocessing")
239
+ processed_x, processed_labels, target_w = preprocess_raman_dataset(
240
+ spectral,
241
+ labels,
242
+ wavenumbers,
243
+ target_len=3500,
244
+ low_cm=0.0,
245
+ high_cm=3500.0,
246
+ eps_fill=1e-8,
247
+ )
248
+
249
+ _save_npy(os.path.join(run_dir, "processed_spectral.npy"), processed_x)
250
+ _save_npy(os.path.join(run_dir, "processed_labels.npy"), processed_labels)
251
+ _save_npy(os.path.join(run_dir, "processed_wavenumbers.npy"), target_w)
252
+
253
+ le = LabelEncoder()
254
+ y_encoded = le.fit_transform(processed_labels)
255
+ raw_class_names = [str(x) for x in le.classes_]
256
+ label_mapping = None
257
+ if input_paths.get("label_mapping") and os.path.isfile(input_paths["label_mapping"]):
258
+ label_mapping = load_label_mapping(input_paths["label_mapping"])
259
+ class_names = apply_label_mapping(raw_class_names, label_mapping)
260
+ num_classes = len(raw_class_names)
261
+
262
+ _update_job(jobs, job_id, message="Creating train/val/test splits...", progress=15, phase="splitting")
263
+ x_train, x_val, x_test, y_train, y_val, y_test = stratified_split_with_minimum_samples(
264
+ processed_x,
265
+ y_encoded,
266
+ test_size=0.15,
267
+ val_size=0.15,
268
+ min_samples_per_class=1,
269
+ random_state=42,
270
+ )
271
+
272
+ class_counts = np.bincount(y_train, minlength=num_classes)
273
+ has_small_classes = bool(np.any(class_counts < 100))
274
+
275
+ if has_small_classes:
276
+ _update_job(jobs, job_id, message="Applying augmentation on small training set...", progress=20, phase="augmentation")
277
+ x_train, y_train = augment_small_trainset(
278
+ x_train,
279
+ y_train,
280
+ target_per_class=100,
281
+ seed=42,
282
+ )
283
+
284
+ train_dataset = RamanDataset(x_train, None, labels=y_train, transform=None, is_train=True)
285
+ val_dataset = RamanDataset(x_val, None, labels=y_val, transform=None, is_train=False)
286
+ test_dataset = RamanDataset(x_test, None, labels=y_test, transform=None, is_train=False)
287
+
288
+ train_loader = DataLoader(train_dataset, batch_size=config.batch_size, shuffle=True, drop_last=False)
289
+ val_loader = DataLoader(val_dataset, batch_size=config.batch_size, shuffle=False)
290
+ test_loader = DataLoader(test_dataset, batch_size=config.batch_size, shuffle=False)
291
+
292
+ device, device_info = _resolve_device_info()
293
+ _update_job(jobs, job_id, **device_info)
294
+
295
+ _update_job(jobs, job_id, message="Loading model and fine-tuning...", progress=30, phase="loading_model")
296
+ progress_reporter = lambda **kwargs: _training_progress_update(jobs, job_id, **kwargs)
297
+ classifier, _, _, mae_model = load_mae_model_for_classification(
298
+ input_paths["model"],
299
+ input_length=3500,
300
+ patch_num=config.patch_num,
301
+ embedding_dim=config.embedding_dim,
302
+ num_layers=config.num_layers,
303
+ num_heads=config.num_heads,
304
+ num_classes=num_classes,
305
+ device=device,
306
+ )
307
+
308
+ trained_model, _ = train_predictor(
309
+ classifier=classifier,
310
+ mae_model=mae_model,
311
+ train_loader=train_loader,
312
+ val_loader=val_loader,
313
+ test_loader=test_loader,
314
+ device=device,
315
+ epochs=config.epochs,
316
+ lr=config.lr,
317
+ weight_decay=config.weight_decay,
318
+ patience=config.patience,
319
+ save_dir=run_dir,
320
+ model_name="raman_web",
321
+ freeze_encoder=config.freeze_encoder,
322
+ label_smoothing=config.label_smoothing,
323
+ progress_callback=progress_reporter,
324
+ )
325
+
326
+ _update_job(jobs, job_id, message="Evaluating on the test set...", progress=90, phase="evaluation")
327
+
328
+ final_model_path = os.path.join(run_dir, "final_model.pth")
329
+ torch.save(
330
+ {
331
+ "model_state_dict": trained_model.state_dict(),
332
+ "model_config": {
333
+ "input_length": 3500,
334
+ "patch_num": config.patch_num,
335
+ "embedding_dim": config.embedding_dim,
336
+ "num_layers": config.num_layers,
337
+ "num_heads": config.num_heads,
338
+ "num_classes": num_classes,
339
+ },
340
+ "class_names": raw_class_names,
341
+ "label_mapping": class_names,
342
+ "preprocess_config": {
343
+ "target_len": 3500,
344
+ "low_cm": 0.0,
345
+ "high_cm": 3500.0,
346
+ "eps_fill": 1e-8,
347
+ },
348
+ },
349
+ final_model_path,
350
+ )
351
+
352
+ best_class_model_path = os.path.join(run_dir, "raman_web_best_class.pth")
353
+ if os.path.exists(best_class_model_path):
354
+ ckpt = _torch_load_safe(best_class_model_path, device)
355
+ classifier.load_state_dict(ckpt["model_state_dict"])
356
+
357
+ jobs[job_id]["message"] = "Running test evaluation and visualization..."
358
+ results = visualize_model_performance(
359
+ classifier,
360
+ test_loader,
361
+ device,
362
+ class_names=class_names,
363
+ save_dir=run_dir,
364
+ )
365
+
366
+ y_true = results["true_labels"]
367
+ y_pred = results["pred_labels"]
368
+ test_accuracy = float(accuracy_score(y_true, y_pred))
369
+ test_macro_f1 = float(f1_score(y_true, y_pred, average="macro", zero_division=0))
370
+
371
+ summary = {
372
+ "job_id": job_id,
373
+ "num_classes": num_classes,
374
+ "train_size": int(len(train_dataset)),
375
+ "val_size": int(len(val_dataset)),
376
+ "test_size": int(len(test_dataset)),
377
+ "class_counts_before_aug": class_counts.tolist(),
378
+ "has_classes_below_100_before_aug": has_small_classes,
379
+ "test_accuracy": test_accuracy,
380
+ "test_macro_f1": test_macro_f1,
381
+ "run_dir": run_dir,
382
+ "final_model": final_model_path,
383
+ "class_names": class_names,
384
+ "model_config": {
385
+ "input_length": 3500,
386
+ "patch_num": config.patch_num,
387
+ "embedding_dim": config.embedding_dim,
388
+ "num_layers": config.num_layers,
389
+ "num_heads": config.num_heads,
390
+ "num_classes": num_classes,
391
+ },
392
+ "preprocess_config": {
393
+ "target_len": 3500,
394
+ "low_cm": 0.0,
395
+ "high_cm": 3500.0,
396
+ "eps_fill": 1e-8,
397
+ },
398
+ "raw_class_names": raw_class_names,
399
+ "label_mapping": class_names,
400
+ "label_mapping_source": os.path.basename(input_paths["label_mapping"]) if input_paths.get("label_mapping") else None,
401
+ "artifacts": {
402
+ "training_history": "training_history.png",
403
+ "training_recon_history": "training_recon_history.png",
404
+ "tsne": "tsne_visualization.png",
405
+ "confusion_matrix": "confusion_matrix_normalized.png",
406
+ "classification_metrics": "classification_metrics.png",
407
+ "roc_curves": "roc_curves.png",
408
+ "classification_report": "classification_report.txt",
409
+ "final_model": "final_model.pth",
410
+ "best_class_model": "raman_web_best_class.pth",
411
+ "best_recon_model": "raman_web_best_recon.pth",
412
+ "processed_spectral": "processed_spectral.npy",
413
+ "processed_labels": "processed_labels.npy",
414
+ "processed_wavenumbers": "processed_wavenumbers.npy",
415
+ },
416
+ }
417
+
418
+ with open(os.path.join(run_dir, "job_summary.json"), "w", encoding="utf-8") as f:
419
+ json.dump(summary, f, indent=2)
420
+
421
+ _update_job(
422
+ jobs,
423
+ job_id,
424
+ status="done",
425
+ message="Completed",
426
+ summary=summary,
427
+ progress=100,
428
+ phase="completed",
429
+ current_epoch=config.epochs,
430
+ total_epochs=config.epochs,
431
+ )
432
+ except Exception as exc:
433
+ _update_job(
434
+ jobs,
435
+ job_id,
436
+ status="error",
437
+ message=str(exc),
438
+ traceback=traceback.format_exc(),
439
+ progress=jobs.get(job_id, {}).get("progress", 0),
440
+ phase="error",
441
+ )