Initial commit with GEMS model and Dockerfile
Browse files- main/GEMS.py +242 -0
- main/Raman_Task.py +992 -0
- main/Ramandataset.py +380 -0
- main/__init__.py +0 -0
- main/__pycache__/GEMS.cpython-312.pyc +0 -0
- main/__pycache__/Raman_Task.cpython-312.pyc +0 -0
- main/__pycache__/Ramandataset.cpython-312.pyc +0 -0
- main/__pycache__/__init__.cpython-312.pyc +0 -0
- main/__pycache__/data_augumentation.cpython-312.pyc +0 -0
- main/__pycache__/evaluate_few_shot_models_fixed_test.cpython-312.pyc +0 -0
- main/__pycache__/evaluate_visualize.cpython-312.pyc +0 -0
- main/__pycache__/hyperpara_optim_contrastive_weight.cpython-312.pyc +0 -0
- main/__pycache__/hyperpara_optim_downstream.cpython-312.pyc +0 -0
- main/__pycache__/hyperpara_optim_pretrain.cpython-312.pyc +0 -0
- main/__pycache__/load_data.cpython-312.pyc +0 -0
- main/__pycache__/pretext.cpython-312.pyc +0 -0
- main/__pycache__/sample_ig_spectrum_check.cpython-312.pyc +0 -0
- main/data_augumentation.py +195 -0
- main/evaluate_few_shot_models_fixed_test.py +470 -0
- main/evaluate_visualize.py +1270 -0
- main/few_shot_bacteria_finetune.py +490 -0
- main/few_shot_cir.py +246 -0
- main/finetune.py +206 -0
- main/hyperpara_optim.py +343 -0
- main/hyperpara_optim_contrastive_weight.py +422 -0
- main/hyperpara_optim_downstream.py +173 -0
- main/hyperpara_optim_pipeline.py +78 -0
- main/hyperpara_optim_pretrain.py +236 -0
- main/load_data.py +114 -0
- main/plot_two_stage_hpo_a4.py +192 -0
- main/pretext.py +554 -0
- main/requirements.txt +12 -0
- webserver/Dockerfile +18 -0
- webserver/__pycache__/app.cpython-312.pyc +0 -0
- webserver/__pycache__/label_utils.cpython-312.pyc +0 -0
- webserver/__pycache__/preprocess_utils.cpython-312.pyc +0 -0
- webserver/__pycache__/train_service.cpython-312.pyc +0 -0
- webserver/app.py +663 -0
- webserver/label_utils.py +65 -0
- webserver/preprocess_utils.py +140 -0
- webserver/requirements.txt +8 -0
- webserver/templates/index.html +487 -0
- webserver/templates/predict.html +277 -0
- webserver/templates/predict_result_fragment.html +69 -0
- webserver/templates/status.html +202 -0
- 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 |
+
)
|