diff --git a/main.py b/main.py index d55df8f..305d529 100644 --- a/main.py +++ b/main.py @@ -6,12 +6,8 @@ def get_venv_python() -> Path: - """ - 현재 프로젝트의 .venv 내 Python 실행 파일 경로를 반환합니다. - """ - base_path = Path.cwd() # 현재 작업 경로 + base_path = Path.cwd() - # OS에 따른 실행 파일 경로 분기 if platform.system() == "Windows": venv_python = base_path / ".venv" / "Scripts" / "python.exe" else: @@ -26,22 +22,16 @@ def get_venv_python() -> Path: def find_exp_folder(exp_num: int, runs_dir_path: Path = Path("./runs")) -> Path: - """ - runs 폴더 내에서 exp{num} 또는 exp0{num}으로 시작하는 폴더를 찾습니다. - """ - # runs 폴더 존재 확인 + """Folder under runs/ whose name starts with exp{num}_ or exp0{num}_.""" if not runs_dir_path.exists(): print(f"❌ [Error] '{runs_dir_path}' 디렉토리가 존재하지 않습니다.") sys.exit(1) - # 검색할 접두사 패턴 (예: exp5_, exp05_) target_prefixes = [f"exp{exp_num}_", f"exp{exp_num:02d}_"] - # iterdir()로 폴더 순회하며 찾기 found_folder = None for item in runs_dir_path.iterdir(): if item.is_dir(): - # 폴더 이름이 접두사 중 하나로 시작하는지 확인 if any(item.name.startswith(prefix) for prefix in target_prefixes): found_folder = item break @@ -55,9 +45,6 @@ def find_exp_folder(exp_num: int, runs_dir_path: Path = Path("./runs")) -> Path: def run_main(target_folder: Path): - """ - 찾은 폴더 내부의 main.py를 가상환경 Python으로 실행합니다. - """ venv_python = get_venv_python() target_script = target_folder / "main.py" @@ -70,10 +57,9 @@ def run_main(target_folder: Path): print("-" * 50) try: - # subprocess에는 Path 객체를 문자열로 변환(str)해서 넘기는 것이 안전합니다. subprocess.run( [str(venv_python), "main.py"], - cwd=target_folder, # 작업 디렉토리를 해당 실험 폴더로 변경 + cwd=target_folder, check=True, ) except subprocess.CalledProcessError as e: @@ -87,8 +73,6 @@ def run_main(target_folder: Path): args = parser.parse_args() - # 1. 대상 폴더 찾기 target_folder = find_exp_folder(args.exp_num) - # 2. 실행 run_main(target_folder) diff --git a/runs/exp01_quan_class/dataset.py b/runs/exp01_quan_class/dataset.py index e6ed865..ff8d82d 100644 --- a/runs/exp01_quan_class/dataset.py +++ b/runs/exp01_quan_class/dataset.py @@ -34,9 +34,6 @@ def __init__(self, thickness_bins: np.ndarray | None = None, roughness_bins: np. self.roughness_bins = np.linspace(0.0, 10.0, 21) if roughness_bins is None else np.asarray(roughness_bins) self.sld_bins = np.linspace(1.0, 6.0, 26) if sld_bins is None else np.asarray(sld_bins) - # -------------------------- - # Persistence - # -------------------------- def state_dict(self): return { "thickness_bins": self.thickness_bins.astype(float).tolist(), @@ -49,9 +46,6 @@ def load_state_dict(self, state: dict): self.roughness_bins = np.asarray(state["roughness_bins"], dtype=float) self.sld_bins = np.asarray(state["sld_bins"], dtype=float) - # -------------------------- - # Fitting helper - # -------------------------- def fit_from_dataset(self, h5_path: str, sample_n: int = 1000, rng_seed: int = 0): """Estimate reasonable bins from real dataset values (sample subset).""" rng = np.random.default_rng(rng_seed) @@ -83,9 +77,6 @@ def fit_from_dataset(self, h5_path: str, sample_n: int = 1000, rng_seed: int = 0 self.roughness_bins = np.linspace(max(0.0, rvals.min()), rvals.max(), 21) self.sld_bins = np.linspace(svals.min(), svals.max(), 26) - # -------------------------- - # Quantize API - # -------------------------- @staticmethod def _quantize_array(vals: np.ndarray, bins: np.ndarray) -> np.ndarray: """Vectorized quantization. Returns indices clipped to valid range [0, len(bins)-2].""" @@ -160,9 +151,6 @@ def __init__( def __len__(self) -> int: return int(self._length) - # -------------------------- - # HDF5 access helpers - # -------------------------- def _ensure_open(self) -> h5py.File: """Open file per-process (safe with num_workers spawn/fork).""" if self._hf is None: @@ -190,9 +178,6 @@ def _estimate_stats(self, n_sample: int = 1000) -> tuple[float, float]: return 0.0, 1.0 return float(arr.mean()), float(arr.std() if arr.std() > 0 else 1.0) - # -------------------------- - # Data conversion - # -------------------------- def _process_reflectivity(self, refl: np.ndarray) -> np.ndarray: """Apply log-scaling, downsampling, normalization and return float32 array.""" arr = np.asarray(refl, dtype=float).ravel() diff --git a/runs/exp01_quan_class/evaluate.py b/runs/exp01_quan_class/evaluate.py index 352e65a..9551e4f 100644 --- a/runs/exp01_quan_class/evaluate.py +++ b/runs/exp01_quan_class/evaluate.py @@ -20,7 +20,7 @@ def __init__( checkpoint_path: Path | str, batch_size: int = 64, device: str | torch.device = None, - num_workers: int = 0, # Windows에서는 0~2 권장 + num_workers: int = 0, # 0-2 recommended on Windows pin_memory: bool = True, ): self.h5_path = Path(h5_path) @@ -50,7 +50,6 @@ def __init__( self.model = XRRClassifier(q_len=self.q_len, n_layers=self.n_layers, n_th_bins=self.n_th, n_rg_bins=self.n_rg, n_sld_bins=self.n_sld).to( self.device ) - # --- Safe checkpoint loading --- if self.checkpoint_path and self.checkpoint_path.is_file(): try: state = torch.load(self.checkpoint_path, map_location=self.device) @@ -67,7 +66,6 @@ def __init__( self.ce = nn.CrossEntropyLoss() def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): - """모든 평가 데이터를 계산하고 결과 저장""" all_tgt_th, all_tgt_rg, all_tgt_sld = [], [], [] all_pred_th, all_pred_rg, all_pred_sld = [], [], [] all_losses = [] @@ -80,25 +78,21 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): refl = refl.to(self.device) labels = labels.to(self.device) - # --- AMP 실제 적용 --- if use_amp and self.device.type == "cuda": with torch.amp.autocast("cuda"): th_logits, rg_logits, sld_logits = self.model(refl) else: th_logits, rg_logits, sld_logits = self.model(refl) - # --- logits clipping (학습과 동일하게) --- if logits_clip is not None: th_logits = th_logits.clamp(-logits_clip, logits_clip) rg_logits = rg_logits.clamp(-logits_clip, logits_clip) sld_logits = sld_logits.clamp(-logits_clip, logits_clip) - # --- target --- tgt_th = labels[:, :, 0] tgt_rg = labels[:, :, 1] tgt_sld = labels[:, :, 2] - # --- Loss 계산(CrossEntropy 조합) --- loss = ( self.ce(th_logits.reshape(-1, th_logits.shape[-1]), tgt_th.reshape(-1)) + self.ce(rg_logits.reshape(-1, rg_logits.shape[-1]), tgt_rg.reshape(-1)) @@ -106,7 +100,6 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): ) all_losses.append(loss.item()) - # --- 예측 저장 --- all_tgt_th.append(tgt_th.cpu().numpy()) all_tgt_rg.append(tgt_rg.cpu().numpy()) all_tgt_sld.append(tgt_sld.cpu().numpy()) @@ -117,7 +110,6 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): pbar.set_postfix({"loss": f"{loss.item():.4f}"}) - # --- Flatten --- self.all_tgt_th = np.concatenate(all_tgt_th).ravel() self.all_tgt_rg = np.concatenate(all_tgt_rg).ravel() self.all_tgt_sld = np.concatenate(all_tgt_sld).ravel() @@ -128,7 +120,6 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): self.all_losses = all_losses - # --- Accuracy --- self.acc_th = (self.all_pred_th == self.all_tgt_th).mean() self.acc_rg = (self.all_pred_rg == self.all_tgt_rg).mean() self.acc_sld = (self.all_pred_sld == self.all_tgt_sld).mean() @@ -203,11 +194,9 @@ def plot_scatter(self, use_hexbin: bool = False): else: plt.scatter(tgt, pred, alpha=0.2, s=8) - # y = x선 (이상적 예측) tmin, tmax = tgt.min(), tgt.max() plt.plot([tmin, tmax], [tmin, tmax], "r--", linewidth=1.5, label="Ideal") - # 추세선 (회귀) coef = np.polyfit(tgt, pred, 1) poly = np.poly1d(coef) plt.plot([tmin, tmax], poly([tmin, tmax]), "b-", linewidth=1.2, label=f"Fit: y={coef[0]:.2f}x+{coef[1]:.2f}") @@ -249,7 +238,7 @@ def plot_correlation(self): h5_path=Path(os.environ["DATA"], r"XRR_AI\data\p300o6_raw.h5"), checkpoint_path=Path(os.environ["DATA"], r"XRR_AI\model\xrr_model.pt"), batch_size=64, - num_workers=0, # Windows 안정성 + num_workers=0, # 0 for stability on Windows ) metrics = evaluator.evaluate(use_amp=True) print(f"Validation Loss: {metrics['loss']:.4f}") diff --git a/runs/exp01_quan_class/model.py b/runs/exp01_quan_class/model.py index d116bc3..d8e6bb0 100644 --- a/runs/exp01_quan_class/model.py +++ b/runs/exp01_quan_class/model.py @@ -114,9 +114,6 @@ def forward(self, x): return logits_th, logits_rg, logits_sld -# ------------------------- -# Loss helper -# ------------------------- def multitask_loss( logits_th, logits_rg, @@ -151,9 +148,6 @@ def accuracy_from_logits(logits, targets): return correct.mean().item() -# ------------------------- -# Training / Validation -# ------------------------- def train_epoch( model: nn.Module, loader: torch.utils.data.DataLoader, @@ -229,7 +223,6 @@ def validate_epoch( logits_th, logits_rg, logits_sld = model(refl) - # --- 안정성 확보: logits clipping --- if logits_clip is not None: logits_th = logits_th.clamp(-logits_clip, logits_clip) logits_rg = logits_rg.clamp(-logits_clip, logits_clip) diff --git a/runs/exp01_quan_class/train.py b/runs/exp01_quan_class/train.py index a25b7a5..8fab52a 100644 --- a/runs/exp01_quan_class/train.py +++ b/runs/exp01_quan_class/train.py @@ -92,7 +92,6 @@ def train(self, save_every: int = 5): total_loss += loss.item() last_parts = {"th": loss_th.item(), "rg": loss_rg.item(), "sld": loss_sld.item()} - # tqdm에 실시간 표시 pbar.set_postfix({**last_parts, "loss": f"{loss.item():.4f}"}) avg_train_loss = total_loss / len(self.train_loader) diff --git a/runs/exp02_one_layer/config.py b/runs/exp02_one_layer/config.py index 2049176..f68e6da 100644 --- a/runs/exp02_one_layer/config.py +++ b/runs/exp02_one_layer/config.py @@ -1,7 +1,6 @@ import os from pathlib import Path -# ==================== 설정 (하나의 파일에서 모두 관리) ==================== CONFIG = { "exp_name": "footprint2", "base_dir": Path(os.environ["DATA"], r"XRR_AI\data\one_layer"), diff --git a/runs/exp02_one_layer/dataset.py b/runs/exp02_one_layer/dataset.py index d2de940..3c62492 100644 --- a/runs/exp02_one_layer/dataset.py +++ b/runs/exp02_one_layer/dataset.py @@ -7,8 +7,6 @@ class XRR1LayerDataset(Dataset): - """1-layer 전용 Dataset""" - def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "train", val_ratio: float = 0.2): self.h5_path = Path(h5_file) self.stats_path = stats_file @@ -23,7 +21,6 @@ def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "tra self.n_total = len(self.thickness) - # 명확한 분할 (Train: 70%, Val: 20%, Test: 10%) test_ratio = 0.1 train_ratio = 1.0 - val_ratio - test_ratio @@ -40,9 +37,8 @@ def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "tra self._setup_normalization() def _setup_normalization(self): - """정규화 파라미터 설정""" if self.mode == "train": - # 통계 계산 (train 부분만) - val_ratio에 따라 계산된 실제 분할 경계 사용 + # statistics from the train split only train_indices = range(self.train_end) R_log = np.log10(np.maximum(self.reflectivity[train_indices], 1e-15)) @@ -55,7 +51,6 @@ def _setup_normalization(self): self.param_mean = np.mean(params, axis=0) self.param_std = np.std(params, axis=0) - # torch tensor로 저장하여 torch.load 문제 해결 torch.save( { "refl_mean": torch.from_numpy(self.refl_mean), @@ -68,7 +63,6 @@ def _setup_normalization(self): print(f"정규화 파라미터 저장: {self.stats_path}") else: - # 저장된 tensor 로드 stats = torch.load(self.stats_path) self.refl_mean = stats["refl_mean"].numpy() self.refl_std = stats["refl_std"].numpy() @@ -81,12 +75,10 @@ def __len__(self): def __getitem__(self, idx): real_idx = self.indices[idx] - # 반사율 처리 R = self.reflectivity[real_idx] R_log = np.log10(np.maximum(R, 1e-15)) R_norm = (R_log - self.refl_mean) / self.refl_std - # 파라미터 params = np.array([self.thickness[real_idx], self.roughness[real_idx], self.sld[real_idx]], dtype=np.float32) params_norm = (params - self.param_mean) / self.param_std @@ -94,7 +86,7 @@ def __getitem__(self, idx): return (torch.from_numpy(R_norm).float(), torch.from_numpy(params_norm).float()) def denormalize_params(self, params_norm: torch.Tensor | np.ndarray) -> np.ndarray: - """모델 출력(정규화된)을 실제 물리 파라미터 스케일로 복원.""" + """Map normalized model outputs back to physical parameter units.""" if isinstance(params_norm, torch.Tensor): params_norm = params_norm.detach().cpu().numpy() @@ -102,9 +94,7 @@ def denormalize_params(self, params_norm: torch.Tensor | np.ndarray) -> np.ndarr return params def normalize_reflectivity(self, R_real: np.ndarray) -> torch.Tensor: - """실험 R 데이터를 모델 입력용으로 정규화.""" R_log = np.log10(np.maximum(R_real, 1e-15)) - # 학습 시 저장된 mean/std 사용 R_norm = (R_log - self.refl_mean) / self.refl_std return torch.from_numpy(R_norm).float() diff --git a/runs/exp02_one_layer/evaluate.py b/runs/exp02_one_layer/evaluate.py index ef4564d..db6b1b5 100644 --- a/runs/exp02_one_layer/evaluate.py +++ b/runs/exp02_one_layer/evaluate.py @@ -9,12 +9,11 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file: Path | None = None, save_plot: bool = True): """ - 모델 평가: 역정규화 후 물리적 오류 계산 + Evaluate the model; errors are computed after denormalization, in physical units. """ stats_path = Path(stats_path) device = next(model.parameters()).device - # 정규화 파라미터 로드 stats = torch.load(stats_path) param_mean = stats["param_mean"] param_std = stats["param_std"] @@ -32,11 +31,9 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file all_preds_norm.append(pred_norm) all_targets_norm.append(params_norm) - # 역정규화 preds = torch.cat(all_preds_norm) * param_std + param_mean targets = torch.cat(all_targets_norm) * param_std + param_mean - # 오류 계산 errors = preds - targets mae = torch.mean(torch.abs(errors), dim=0) rmse = torch.sqrt(torch.mean(errors**2, dim=0)) @@ -49,7 +46,6 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file print(f"{name:15s}: MAE={mae[i]:7.3f} | RMSE={rmse[i]:7.3f}") print("=" * 50) - # 시각화 if save_plot: fig, axes = plt.subplots(1, 3, figsize=(15, 4)) @@ -75,17 +71,13 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file def load_checkpoint_and_evaluate(test_loader: DataLoader, checkpoint_path: Path, stats_path: Path, report_file: Path): - """체크포인트 로드 및 평가""" - # 체크포인트 로드 ckpt = torch.load(checkpoint_path, map_location="cpu") config = ckpt["config"]["model_args"] - # 모든 인자로 모델 생성 model = XRR1DRegressor(**config) model.load_state_dict(ckpt["model_state_dict"]) model = model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu")) - # 평가 evaluate(model, test_loader, stats_path, report_file) diff --git a/runs/exp02_one_layer/evaluate_with_real_data.py b/runs/exp02_one_layer/evaluate_with_real_data.py index 809b885..f9bd3d8 100644 --- a/runs/exp02_one_layer/evaluate_with_real_data.py +++ b/runs/exp02_one_layer/evaluate_with_real_data.py @@ -7,17 +7,13 @@ from torch.utils.data import DataLoader, TensorDataset -# ------------------------------ -# Helper: 보간하여 모델의 q-grid에 맞추기 -# ------------------------------ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarray) -> np.ndarray: """ - meas_q: (M,) 실제 측정 q - meas_I: (M,) 또는 (N_samples, M) 측정 intensity - target_q: (Q,) 모델 q-grid - 반환: (Q,) 또는 (N_samples, Q) + meas_q: (M,) measured q + meas_I: (M,) or (N_samples, M) measured intensity + target_q: (Q,) model q-grid + Returns: (Q,) or (N_samples, Q) """ - # 만약 여러 샘플이면 first axis가 샘플로 오게 정리 meas_I = np.atleast_2d(meas_I) out = np.empty((meas_I.shape[0], target_q.size), dtype=float) for i, row in enumerate(meas_I): @@ -27,9 +23,6 @@ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarra return out -# ------------------------------ -# Helper: stats 불러와 파라미터 정규화/역정규화 -# ------------------------------ def load_stats(stats_path: Path) -> tuple[np.ndarray, np.ndarray]: st = torch.load(stats_path) param_mean = st["param_mean"].numpy() if isinstance(st["param_mean"], torch.Tensor) else np.array(st["param_mean"]) @@ -45,9 +38,6 @@ def denormalize_params(params_norm: np.ndarray, param_mean: np.ndarray, param_st return params_norm * param_std + param_mean -# ------------------------------ -# Create DataLoader from raw measured arrays -# ------------------------------ def make_dataloader_from_arrays( measured_q: np.ndarray, measured_I: np.ndarray, # shape (N, M) or (M,) @@ -87,9 +77,6 @@ def make_dataloader_from_arrays( return loader, param_mean, param_std -# ------------------------------ -# Main: evaluate and additional visualizations -# ------------------------------ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_prefix: Path, device=None): device = device or (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")) model = model.to(device) @@ -178,34 +165,24 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr return preds, targets, errors -# ------------------------------ -# Example usage block (사용자 환경에 맞게 수정) -# ------------------------------ if __name__ == "__main__": from config import CONFIG from model import XRR1DRegressor - # ----------------------------- - # 경로 설정 - # ----------------------------- # The run main.py trains, not another experiment's folder. exp_dir = CONFIG["base_dir"] / CONFIG["exp_name"] stats_file = exp_dir / "stats.pt" checkpoint_file = exp_dir / "best.pt" out_dir = exp_dir / "eval_real" - # 실제 측정 데이터 디렉토리 real_data_dir = Path(os.environ["DATA"], r"XRR_AI\data\one_layer\real_tester") measured_q = np.load(real_data_dir / "measured_q.npy") # (M,) - measured_I = np.load(real_data_dir / "measured_I.npy") # (N, M) 또는 (M,) + measured_I = np.load(real_data_dir / "measured_I.npy") # (N, M) or (M,) try: measured_params = np.load(real_data_dir / "measured_params.npy") # (N, 3) except FileNotFoundError: measured_params = None - # ----------------------------- - # 모델 불러오기 - # ----------------------------- ckpt = torch.load(checkpoint_file, map_location="cpu") config = ckpt["config"]["model_args"] @@ -213,9 +190,6 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr model.load_state_dict(ckpt["model_state_dict"]) model = model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu")) - # ----------------------------- - # 학습 때 q-grid 재생성 - # ----------------------------- q_points = 200 wavelength = 1.54 # Å tth_min = 1.0 # deg @@ -226,9 +200,6 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr theta = np.deg2rad(tth_deg / 2) q_target = 4 * np.pi / wavelength * np.sin(theta) # Å^-1 - # ----------------------------- - # dataloader 생성 - # ----------------------------- loader, mean, std = make_dataloader_from_arrays( measured_q=measured_q, measured_I=measured_I, @@ -238,7 +209,4 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr batch_size=64, ) - # ----------------------------- - # 평가 - # ----------------------------- preds, targets, errors = evaluate_with_plots(model, loader, stats_file, out_dir) diff --git a/runs/exp02_one_layer/main.py b/runs/exp02_one_layer/main.py index 1152f1a..66e988a 100644 --- a/runs/exp02_one_layer/main.py +++ b/runs/exp02_one_layer/main.py @@ -6,10 +6,8 @@ from evaluate import load_checkpoint_and_evaluate from torch.utils.data import DataLoader -# 재현성 보장 torch.manual_seed(42) np.random.seed(42) -# ========================================================================= def main(): @@ -39,7 +37,6 @@ def main(): print(f"데이터셋: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") - # 모델 생성 from model import XRR1DRegressor model = XRR1DRegressor( @@ -52,7 +49,6 @@ def main(): print(f"모델 파라미터: {sum(p.numel() for p in model.parameters()):,}") - # 학습 from train import Trainer trainer = Trainer( @@ -64,10 +60,8 @@ def main(): weight_decay=CONFIG["training"]["weight_decay"], ) - # 학습 시작 (체크포인트 있으면 자동 로드) trainer.train(CONFIG["training"]["epochs"]) - # 최종 평가 print("\n" + "=" * 50) print("최종 테스트 평가") print("=" * 50) diff --git a/runs/exp02_one_layer/make_one_fake.py b/runs/exp02_one_layer/make_one_fake.py index 8de6976..4347e21 100644 --- a/runs/exp02_one_layer/make_one_fake.py +++ b/runs/exp02_one_layer/make_one_fake.py @@ -20,7 +20,6 @@ def generate_dummy_xrr(qs, param: ParamSet): if __name__ == "__main__": from pathlib import Path - # q-space 생성 (당신 config와 동일) wavelen: float = 1.54 # (nm) tth_min: float = 1.0 # degree tth_max: float = 6.0 diff --git a/runs/exp02_one_layer/model.py b/runs/exp02_one_layer/model.py index b986b50..9690de1 100644 --- a/runs/exp02_one_layer/model.py +++ b/runs/exp02_one_layer/model.py @@ -34,7 +34,6 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: # Global pooling self.global_pool = nn.AdaptiveAvgPool1d(1) - # MLP 회귀기 encoder_out_dim = in_ch # Should be 512 for depth=4 self.regressor = nn.Sequential( nn.Linear(encoder_out_dim, mlp_hidden), @@ -42,13 +41,12 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: nn.Dropout(dropout), nn.Linear(mlp_hidden, mlp_hidden // 2), nn.ReLU(inplace=True), - nn.Linear(mlp_hidden // 2, 3), # 3개 파라미터 출력 + nn.Linear(mlp_hidden // 2, 3), ) self._init_weights() def _init_weights(self): - """He 초기화""" for m in self.modules(): if isinstance(m, nn.Conv1d) or isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") @@ -57,7 +55,7 @@ def _init_weights(self): def forward(self, x: torch.Tensor) -> torch.Tensor: if x.dim() == 2: - x = x.unsqueeze(1) # 채널 차원 추가 + x = x.unsqueeze(1) features = self.encoder(x) # (B, ch, seq_len') pooled = self.global_pool(features).squeeze(-1) # (B, ch) diff --git a/runs/exp02_one_layer/simulate.py b/runs/exp02_one_layer/simulate.py index fc5a725..b04169b 100644 --- a/runs/exp02_one_layer/simulate.py +++ b/runs/exp02_one_layer/simulate.py @@ -8,27 +8,23 @@ def generate_1layer_data(config: dict, h5_file: Path | str): """ - 1-layer XRR 데이터 생성 + Generate 1-layer XRR data. Args: - config: main.py의 CONFIG 딕셔너리 (simulation, paths, param_ranges 포함) + config: CONFIG dict of main.py (simulation, paths, param_ranges) """ print("=== 1-Layer XRR 데이터 생성 시작 ===") - # config에서 모든 파라미터 추출 simulation = config["simulation"] param_ranges = config["param_ranges"] h5_file = Path(h5_file) - # 출력 디렉토리 생성 output_dir = h5_file.parent output_dir.mkdir(exist_ok=True, parents=True) - # q 벡터 생성 q_min = tth2q(simulation["tth_min"], simulation["wavelength"]) q_max = tth2q(simulation["tth_max"], simulation["wavelength"]) q_values = np.linspace(q_min, q_max, simulation["q_points"]) - # 시뮬레이터 초기화 (n_layers=1) simulator = XRRSimulator( qs=q_values, n_layers=1, diff --git a/runs/exp02_one_layer/train.py b/runs/exp02_one_layer/train.py index a94d84f..032f8af 100644 --- a/runs/exp02_one_layer/train.py +++ b/runs/exp02_one_layer/train.py @@ -63,10 +63,9 @@ def validate(self) -> float: return total_loss / len(self.val_loader.dataset) def train(self, epochs: int, resume_from: str | Path | None = None): - """학습 시작 (이어서 학습 지원)""" + """Train, resuming from a checkpoint when one exists.""" start_epoch = 1 - # 체크포인트 로드 시도 (resume_from 우선, 없으면 best.pt 시도) checkpoint_path = Path(resume_from) if resume_from else self.checkpoint_dir / "best.pt" if checkpoint_path.exists(): @@ -90,7 +89,6 @@ def train(self, epochs: int, resume_from: str | Path | None = None): self.scheduler.step(val_loss) - # 체크포인트 저장 if val_loss < self.best_val_loss: self.best_val_loss = val_loss self.patience_counter = 0 @@ -105,7 +103,6 @@ def train(self, epochs: int, resume_from: str | Path | None = None): break def save_checkpoint(self, filename: str, epoch: int, val_loss: float): - """체크포인트 저장 (학습 상태 포함)""" torch.save( { "epoch": epoch, @@ -123,17 +120,14 @@ def save_checkpoint(self, filename: str, epoch: int, val_loss: float): print(f"Saved checkpoint: {self.checkpoint_dir / filename}") def load_checkpoint(self, filepath: str | Path) -> int: - """체크포인트 로드 (에포크 번호 반환)""" + """Load a checkpoint and return its epoch number.""" checkpoint = torch.load(filepath, map_location=self.device) - # 모델 상태 로드 self.model.load_state_dict(checkpoint["model_state_dict"]) - # 옵티마이저 상태 로드 (없으면 스킵) if "optimizer_state_dict" in checkpoint: self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) - # 스케줄러 상태 로드 (없으면 스킵) if "scheduler_state_dict" in checkpoint: self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"]) diff --git a/runs/exp03_physics_fused/main.py b/runs/exp03_physics_fused/main.py index 0bc72ef..5408d4a 100644 --- a/runs/exp03_physics_fused/main.py +++ b/runs/exp03_physics_fused/main.py @@ -22,7 +22,6 @@ def main(): "save_every": 5, "physics_weight": 0.5, "patience": 10, - # 하드웨어 "device": "cuda" if torch.cuda.is_available() else "cpu", "mixed_precision": True, "num_workers": 4, diff --git a/runs/exp03_physics_fused/physics.py b/runs/exp03_physics_fused/physics.py index ce73124..2842c6c 100644 --- a/runs/exp03_physics_fused/physics.py +++ b/runs/exp03_physics_fused/physics.py @@ -19,10 +19,6 @@ def func_gauss3(x, a1, p1, w1, a2, p2, w2, a3, p3, w3): def xrr_fft(x, y, d=None, window=2, n=None, step_num: int = 1000): - """ - FFT 변환 (XRR 분석 전용) - """ - # 등간격 보간 f_cubic = interpolate.interp1d(x, y, kind="cubic") x = np.linspace(x.min(), x.max(), step_num) y = f_cubic(x) @@ -78,7 +74,7 @@ def __init__( self.dq = float(q_values[1] - q_values[0]) def forward(self, reflectivity: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: - """반환값: (physics_output, validity_mask)""" + """Returns (physics_output, validity_mask).""" B = reflectivity.shape[0] device = reflectivity.device @@ -92,7 +88,6 @@ def forward(self, reflectivity: torch.Tensor) -> tuple[torch.Tensor, torch.Tenso fitted_params = self._process_single_with_fitting(q_np, R_batch_np[i]) if fitted_params is not None: - # 첫 피크 = 첫 번째 두께, 두 번째 피크 = 두 번째 두께 physics_output[i, 0] = float(fitted_params[1]) # p1 physics_output[i, 1] = float(fitted_params[4]) # p2 validity_mask[i] = 1.0 @@ -100,14 +95,11 @@ def forward(self, reflectivity: torch.Tensor) -> tuple[torch.Tensor, torch.Tenso return physics_output, validity_mask def _process_single_with_fitting(self, q: np.ndarray, R: np.ndarray): - """valid_comb 로직 적용 + 피팅""" - # 1. Baseline 제거 + """Apply the valid_comb logic, then fit.""" R_baseline_removed = self._remove_baseline(R) - # 2. FFT x_fft, y_fft = self._compute_fft(q, R_baseline_removed) - # 3. 피크 검출 upper_idx = np.searchsorted(x_fft, self.max_total_thickness) if upper_idx <= 10: return None @@ -118,30 +110,25 @@ def _process_single_with_fitting(self, q: np.ndarray, R: np.ndarray): print(peak_indices) if len(peak_indices) > 7: return None - # 4. 피크 위치 peaks = x_fft[peak_indices] - # 5. valid_comb 로직으로 유효한 조합 찾기 valid_combs = self._find_valid_combinations(peaks) - # 6. 조합이 없으면 fallback: Top 3 피크 + # fall back to the top 3 peaks when no valid combination exists if len(valid_combs) == 0: peak_heights = y_fft[peak_indices] top3_idx = np.argsort(-peak_heights)[:3] top3_peaks = x_fft[peak_indices[top3_idx]] - # 중복 제거 unique_peaks = self._select_unique_peaks(top3_peaks, y_fft, x_fft) if len(unique_peaks) < 3: return None valid_combs = [unique_peaks] - # 7. 각 조합 피팅 & 최적 결과 선택 best_params = None best_score = -1.0 for comb in valid_combs: try: - # 피팅 범위 fit_min = max(0, min(comb) - 10) fit_max = max(comb) + 10 @@ -152,7 +139,6 @@ def _process_single_with_fitting(self, q: np.ndarray, R: np.ndarray): x_fit = x_fft[fit_mask] y_fit = y_fft[fit_mask] - # 초기값 p0 = [0.1, comb[0], 5.0, 0.1, comb[1], 5.0, 0.1, comb[2], 5.0] # # Bounds @@ -160,10 +146,8 @@ def _process_single_with_fitting(self, q: np.ndarray, R: np.ndarray): # bounds_upper = [np.inf, max(x_fit), np.inf, np.inf, max(x_fit), np.inf, np.inf, max(x_fit), np.inf] # bounds = (bounds_lower, bounds_upper) - # 피팅 popt, pcov = curve_fit(func_gauss3, x_fit, y_fit, p0=p0, maxfev=2000) - # 품질 점수 (covariance trace) score = 1.0 / (1.0 + np.trace(pcov)) if score > best_score: @@ -190,7 +174,7 @@ def _find_valid_combinations(self, peaks: np.ndarray) -> list: return result def _select_unique_peaks(self, peaks: np.ndarray, y_fft: np.ndarray, x_fft: np.ndarray): - """중복 제거 + 최소 간격 적용""" + """Drop duplicate peaks and enforce a minimum spacing.""" peak_heights = np.array([y_fft[np.argmin(np.abs(x_fft - p))] for p in peaks]) sorted_idx = np.argsort(-peak_heights) peaks_sorted = peaks[sorted_idx] @@ -244,11 +228,9 @@ def _compute_fft(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.nda physics_layer = PhysicsLayer(qs_tensor, n_layers=2) - # 추정 실행 with torch.no_grad(): est_thickness, confidence = physics_layer(refl_tensor) - # 결과 출력 print("\nPhysicsLayer 추정 결과:") print(f"추정 두께: {est_thickness[0].numpy()} Å") print(confidence) diff --git a/runs/exp03_physics_fused/test/test.py b/runs/exp03_physics_fused/test/test.py index da1e065..32b4698 100644 --- a/runs/exp03_physics_fused/test/test.py +++ b/runs/exp03_physics_fused/test/test.py @@ -17,50 +17,40 @@ class PhysicsValidator: def __init__(self, h5_path: str | Path, norm_ranges: dict = None, device: str = "cuda"): self.device = torch.device(device if torch.cuda.is_available() else "cpu") - # 데이터셋 로드 (train/val 모두) self.dataset = FusedDatasetH5( h5_path, - mode="val", # 검증셋으로 테스트 + mode="val", val_ratio=0.2, norm_ranges=norm_ranges, ) - # q_values 추출 with h5py.File(h5_path, "r") as f: self.q_values = torch.tensor(f["q"][:], dtype=torch.float32) - # PhysicsLayer 초기화 self.physics_layer = PhysicsLayer(q_values=self.q_values, n_layers=self.dataset[0]["targets"].shape[0], confidence_threshold=0.1).to( self.device ) - # 역정규화를 위한 범위 self.norm_ranges = norm_ranges or {"thickness": [0.0, 200.0], "roughness": [0.0, 10.0], "sld": [0.0, 140.0]} def denormalize_thickness(self, thickness_norm: np.ndarray) -> np.ndarray: - """정규화된 두께를 원래 스케일로 복원""" min_val, max_val = self.norm_ranges["thickness"] return (thickness_norm + 1) / 2 * (max_val - min_val) + min_val def validate_sample(self, idx: int, plot: bool = False, save_path: str = None): - """단일 샘플에 대해 physics layer 검증""" sample = self.dataset[idx] - # 실제 값 real_targets = sample["targets"] # (n_layers, 3) real_thickness = self.denormalize_thickness(real_targets[:, 0].numpy()) - # physics layer 추정 reflectivity = sample["reflectivity"].unsqueeze(0).to(self.device) # (1, Q) with torch.no_grad(): physics_output, validity_mask = self.physics_layer(reflectivity) - # 역정규화 - est_thickness = physics_output[0].cpu().numpy() # physics layer는 이미 실제 스케일 + est_thickness = physics_output[0].cpu().numpy() # physics layer output is already in physical units confidence = validity_mask[0].item() - # 오차 계산 mae = np.mean(np.abs(est_thickness[: len(real_thickness)] - real_thickness)) results = { @@ -72,14 +62,12 @@ def validate_sample(self, idx: int, plot: bool = False, save_path: str = None): "is_valid": confidence > 0.1, } - # 시각화 if plot: self.plot_physics_process(sample, physics_output.cpu(), validity_mask.cpu(), save_path=save_path) return results def validate_batch(self, n_samples: int = 100): - """배치 단위로 검증 (통계 계산)""" indices = np.random.choice(len(self.dataset), min(n_samples, len(self.dataset)), replace=False) all_mae = [] @@ -94,7 +82,6 @@ def validate_batch(self, n_samples: int = 100): confidences.append(result["confidence"]) success_rates.append(result["is_valid"]) - # 통계 계산 valid_mae = [mae for mae, valid in zip(all_mae, success_rates, strict=True) if valid] invalid_mae = [mae for mae, valid in zip(all_mae, success_rates, strict=True) if not valid] @@ -112,14 +99,12 @@ def validate_batch(self, n_samples: int = 100): return stats, indices, all_mae def plot_physics_process(self, sample, physics_output, validity_mask, save_path: str = None): - """Physics layer의 중간 과정 시각화""" fig, axes = plt.subplots(3, 1, figsize=(12, 10)) q_np = self.q_values.numpy() R_log_np = sample["reflectivity"].numpy() R_original = 10**R_log_np - # 1. 원본 반사율 axes[0].plot(q_np, R_original, "b-", label="Reflectivity (R)") axes[0].set_xlabel("q") axes[0].set_ylabel("R") @@ -127,7 +112,6 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: axes[0].legend() axes[0].set_yscale("log") - # 2. FFT 결과 및 피크 검출 R_smooth = signal.savgol_filter(R_log_np, window_length=min(len(R_log_np), 51), polyorder=4, mode="interp") R_savgol = R_original / (10**R_smooth) R_savgol = R_savgol / (R_savgol[0] + 1e-12) * R_original.max() @@ -141,7 +125,6 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: axes[1].plot(x_fft, yf, "g-", label="FFT") - # 피크 강조 from scipy.signal import argrelmax upper_idx = np.searchsorted(x_fft, 200.0) @@ -156,7 +139,6 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: axes[1].set_title("FFT Analysis & Peak Detection") axes[1].legend() - # 3. 두께 비교 real_thickness = self.denormalize_thickness(sample["targets"][:, 0].numpy()) est_thickness = physics_output[0].cpu().numpy() @@ -171,7 +153,6 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: axes[2].set_title(f"Thickness Comparison (Confidence: {validity_mask[0].item():.3f})") axes[2].legend() - # MAE 표시 mae = np.mean(np.abs(est_thickness[: len(real_thickness)] - real_thickness)) axes[2].text( 0.02, @@ -191,7 +172,6 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: plt.show() def print_summary(self, stats: dict): - """검증 결과 요약 출력""" print("\n" + "=" * 60) print("📊 Physics Layer Validation Summary") print("=" * 60) @@ -207,10 +187,8 @@ def print_summary(self, stats: dict): print("=" * 60) def plot_error_distribution(self, indices, mae_list, save_path: str | Path = None): - """오차 분포 시각화""" fig, axes = plt.subplots(1, 2, figsize=(12, 5)) - # 1. MAE 히스토그램 axes[0].hist(mae_list, bins=20, edgecolor="black", alpha=0.7) axes[0].set_xlabel("MAE (Å)") axes[0].set_ylabel("Frequency") @@ -218,7 +196,6 @@ def plot_error_distribution(self, indices, mae_list, save_path: str | Path = Non axes[0].axvline(np.mean(mae_list), color="r", linestyle="--", label=f"Mean: {np.mean(mae_list):.2f}") axes[0].legend() - # 2. Confidence vs MAE 산점도 confidences = [] for idx in indices: sample = self.dataset[idx] @@ -248,21 +225,16 @@ def main(): h5_path = Path(r"D:\data\XRR_AI\xrr_data.h5") plot_dir = Path(".") plot_samples = 10 - # 검증기 초기화 validator = PhysicsValidator(h5_path=h5_path, norm_ranges={"thickness": [0.0, 200.0], "roughness": [0.0, 10.0], "sld": [0.0, 140.0]}) - # 결과 저장 디렉토리 plot_dir = Path(plot_dir) plot_dir.mkdir(exist_ok=True) - # 배치 검증 stats, indices, mae_list = validator.validate_batch(n_samples) validator.print_summary(stats) - # 오차 분포 플롯 validator.plot_error_distribution(indices, mae_list, save_path=plot_dir / "error_distribution.png") - # 샘플별 상세 플롯 print(f"\nPlotting {plot_samples} sample details...") plot_indices = np.random.choice(indices, plot_samples, replace=False) diff --git a/runs/exp03_physics_fused/train_fused.py b/runs/exp03_physics_fused/train_fused.py index 575edd0..34ac74a 100644 --- a/runs/exp03_physics_fused/train_fused.py +++ b/runs/exp03_physics_fused/train_fused.py @@ -14,7 +14,6 @@ from dataset_fused import FusedDatasetH5 from model_fused import FusedPhysicsRegressor, fused_loss_with_confidence -# import wandb # 옵션 class FusedTrainer: @@ -22,7 +21,6 @@ def __init__(self, config: dict): self.config = config self.device = torch.device(config.get("device", "cuda" if torch.cuda.is_available() else "cpu")) - # 1. DataLoader 설정 (정규화 범위 전달) self.train_dataset = FusedDatasetH5(config["h5_path"], mode="train", val_ratio=config["val_ratio"], norm_ranges=config.get("norm_ranges")) self.val_dataset = FusedDatasetH5(config["h5_path"], mode="val", val_ratio=config["val_ratio"], norm_ranges=config.get("norm_ranges")) @@ -33,7 +31,6 @@ def __init__(self, config: dict): self.val_dataset, batch_size=config["batch_size"], shuffle=False, num_workers=config.get("num_workers", 4), pin_memory=True ) - # 2. Model 초기화 (정규화 범위 전달) sample = self.train_dataset[0] q_len = sample["reflectivity"].shape[0] n_layers = sample["targets"].shape[0] @@ -43,7 +40,6 @@ def __init__(self, config: dict): q_len=q_len, n_layers=n_layers, q_values=q_values, physics_weight=config.get("physics_weight", 0.5), norm_ranges=config.get("norm_ranges") ).to(self.device) - # 3. Optimizer & Scheduler self.optimizer = AdamW(self.model.parameters(), lr=config["lr"], weight_decay=config["weight_decay"], betas=(0.9, 0.999)) if config.get("use_one_cycle", True): @@ -59,14 +55,11 @@ def __init__(self, config: dict): else: self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=5, factor=0.5, min_lr=1e-6) - # 4. Mixed Precision self.scaler = torch.cuda.amp.GradScaler() if config.get("mixed_precision", True) else None - # 5. Checkpoint 관리 self.ckpt_dir = Path(config["checkpoint_dir"]) / config["exp_name"] self.ckpt_dir.mkdir(parents=True, exist_ok=True) - # 6. 메트릭 추적 self.train_metrics = defaultdict(list) self.val_metrics = defaultdict(list) @@ -74,7 +67,6 @@ def __init__(self, config: dict): # wandb.init(project="xrr-fused", name=config["exp_name"], config=config) def train_epoch(self): - """학습 1 epoch 실행""" self.model.train() epoch_loss = 0.0 physics_confidences = [] @@ -85,12 +77,10 @@ def train_epoch(self): refl = batch["reflectivity"].to(self.device, non_blocking=True) targets = batch["targets"].to(self.device, non_blocking=True) - # 7. Mixed Precision Forward with torch.cuda.autocast("cuda", enabled=self.scaler is not None): outputs = self.model(refl) loss = fused_loss_with_confidence(outputs, targets, self.config["physics_weight"]) - # 8. Backward & Optimizer Step self.optimizer.zero_grad() if self.scaler: @@ -107,7 +97,6 @@ def train_epoch(self): if isinstance(self.scheduler, OneCycleLR): self.scheduler.step() - # 9. 메트릭 수집 batch_loss = loss.item() epoch_loss += batch_loss @@ -124,7 +113,6 @@ def train_epoch(self): return avg_loss, {"confidence": avg_confidence, "physics_contrib": avg_physics_contrib} def validate(self): - """검증 실행""" self.model.eval() epoch_loss = 0.0 physics_confidences = [] @@ -135,7 +123,7 @@ def validate(self): refl = batch["reflectivity"].to(self.device, non_blocking=True) targets = batch["targets"].to(self.device, non_blocking=True) - # 검증시 full precision + # validate in full precision outputs = self.model(refl) loss = fused_loss_with_confidence(outputs, targets, self.config["physics_weight"]) @@ -144,7 +132,6 @@ def validate(self): conf = outputs["confidence"].detach() physics_confidences.append(conf.mean().item()) - # 파라미터별 오차 계산 final_pred = outputs["final"] mae = torch.abs(final_pred - targets).mean(dim=0) @@ -159,7 +146,6 @@ def validate(self): return avg_loss, {"confidence": avg_confidence, "param_mae": param_mae} def train(self): - """전체 학습 루프""" best_val_loss = float("inf") patience = self.config.get("patience", 10) no_improve_epochs = 0 @@ -168,15 +154,12 @@ def train(self): print(f"Train samples: {len(self.train_dataset)}, Val samples: {len(self.val_dataset)}") for epoch in range(1, self.config["epochs"] + 1): - # 12. Train & Validate train_loss, train_metrics = self.train_epoch() val_loss, val_metrics = self.validate() - # 13. Scheduler 업데이트 if isinstance(self.scheduler, ReduceLROnPlateau): self.scheduler.step(val_loss) - # 14. 상세 로깅 print( f"[Epoch {epoch:02d}/{self.config['epochs']}] " f"Loss: {train_loss:.4f} → {val_loss:.4f} | " @@ -188,7 +171,6 @@ def train(self): pmae = val_metrics["param_mae"] print(f" → MAE: Thickness={pmae['thickness']:.3f}, Roughness={pmae['roughness']:.3f}, SLD={pmae['sld']:.3f}") - # 15. WandB 로깅 (옵션) # if self.config.get("use_wandb", False): # wandb.log({ # "epoch": epoch, @@ -199,7 +181,6 @@ def train(self): # **{f"val/mae_{k}": v for k, v in val_metrics.get("param_mae", {}).items()} # }) - # 16. Checkpoint 저장 if val_loss < best_val_loss: best_val_loss = val_loss no_improve_epochs = 0 @@ -211,7 +192,6 @@ def train(self): if epoch % self.config.get("save_every", 5) == 0: self.save_checkpoint(f"epoch_{epoch}.pt", epoch, val_loss, val_metrics) - # 17. Early Stopping if no_improve_epochs >= patience: print(f"Early stopping triggered after {patience} epochs without improvement") break @@ -219,7 +199,6 @@ def train(self): print(f"Training finished. Best val loss: {best_val_loss:.4f}") def save_checkpoint(self, filename: str, epoch: int, val_loss: float, metrics: dict): - """체크포인트 저장""" checkpoint = { "epoch": epoch, "model_state_dict": self.model.state_dict(), @@ -235,7 +214,6 @@ def save_checkpoint(self, filename: str, epoch: int, val_loss: float, metrics: d print(f"Saved: {self.ckpt_dir / filename}") def load_checkpoint(self, filepath: str): - """체크포인트 로드 (재개 학습용)""" checkpoint = torch.load(filepath, map_location=self.device) self.model.load_state_dict(checkpoint["model_state_dict"]) self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) diff --git a/runs/exp04_one_genx/config.py b/runs/exp04_one_genx/config.py index 8b8b748..4fbbd7f 100644 --- a/runs/exp04_one_genx/config.py +++ b/runs/exp04_one_genx/config.py @@ -6,7 +6,6 @@ # _thickness_range = (_mean_thickness * 0.8, _mean_thickness * 1.2) # _sld_range = (_mean_sld * 0.8, _mean_sld * 1.2) -# ==================== 설정 (하나의 파일에서 모두 관리) ==================== CONFIG = { "exp_name": "test", "base_dir": Path(r"D:\data\XRR_AI\mask"), diff --git a/runs/exp04_one_genx/dataset.py b/runs/exp04_one_genx/dataset.py index 8178858..22edf6a 100644 --- a/runs/exp04_one_genx/dataset.py +++ b/runs/exp04_one_genx/dataset.py @@ -7,7 +7,7 @@ class XRR1LayerDataset(Dataset): - """1-layer 전용 Dataset (Reflectivity max-normalization + log10 고정)""" + """Single-layer dataset; reflectivity is max-normalized, then log10.""" def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "train", val_ratio: float = 0.2): @@ -24,7 +24,6 @@ def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "tra self.n_total = len(self.reflectivity) - # 명확한 Train/Val/Test split test_ratio = 0.1 train_ratio = 1.0 - val_ratio - test_ratio @@ -40,16 +39,12 @@ def __init__(self, h5_file: str | Path, stats_file: str | Path, mode: str = "tra self._setup_normalization() - # --------------------------------------------------------- - # 파라미터 정규화 (reflectivity는 max-normalization만 적용하므로 통계 불필요) - # --------------------------------------------------------- + # reflectivity is only max-normalized, so it needs no statistics def _setup_normalization(self): if self.mode == "train": train_indices = range(int(self.n_total * 0.7)) - # reflectivity는 max-normalization만 하므로 평균/분산 없음 - params = np.stack([self.thickness[train_indices], self.roughness[train_indices], self.sld[train_indices]], axis=1) self.param_mean = np.mean(params, axis=0) @@ -67,21 +62,17 @@ def _setup_normalization(self): def __len__(self): return len(self.indices) - # --------------------------------------------------------- - # Reflectivity max-normalization + log10 - # --------------------------------------------------------- def __getitem__(self, idx): real_idx = self.indices[idx] R = self.reflectivity[real_idx] - R_max = np.max(R) # 물리적 측면의 intensity scale 제거 + R_max = np.max(R) # removes the absolute intensity scale R_scaled = R / (R_max + 1e-15) R_log = np.log10(np.maximum(R_scaled, 1e-15)) R_tensor = torch.from_numpy(R_log).float() - # ------------------ params ------------------- params = np.array([self.thickness[real_idx], self.roughness[real_idx], self.sld[real_idx]], dtype=np.float32) params_norm = (params - self.param_mean) / self.param_std @@ -89,27 +80,18 @@ def __getitem__(self, idx): return R_tensor, params_tensor - # --------------------------------------------------------- - # Parameter 역정규화 - # --------------------------------------------------------- def denormalize_params(self, params_norm): if isinstance(params_norm, torch.Tensor): params_norm = params_norm.detach().cpu().numpy() params = params_norm * self.param_std + self.param_mean return params - # --------------------------------------------------------- - # 실측 reflectivity 정규화 (로그 고정) - # --------------------------------------------------------- def normalize_reflectivity(self, R_real: np.ndarray): R_max = np.max(R_real) R_scaled = R_real / (R_max + 1e-15) R_log = np.log10(np.maximum(R_scaled, 1e-15)) return torch.from_numpy(R_log).float(), R_max - # --------------------------------------------------------- - # 역정규화: logR → R × original_max - # --------------------------------------------------------- @staticmethod def denormalize_reflectivity(R_log: np.ndarray, original_max: float): R = 10**R_log diff --git a/runs/exp04_one_genx/evaluate.py b/runs/exp04_one_genx/evaluate.py index db88784..b7b14bf 100644 --- a/runs/exp04_one_genx/evaluate.py +++ b/runs/exp04_one_genx/evaluate.py @@ -9,12 +9,11 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file: Path | None = None, save_plot: bool = True): """ - 모델 평가: 역정규화 후 물리적 오류 계산 + Evaluate the model; errors are computed after denormalization, in physical units. """ stats_path = Path(stats_path) device = next(model.parameters()).device - # 정규화 파라미터 로드 stats = torch.load(stats_path) param_mean = stats["param_mean"] param_std = stats["param_std"] @@ -32,11 +31,9 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file all_preds_norm.append(pred_norm) all_targets_norm.append(params_norm) - # 역정규화 preds = torch.cat(all_preds_norm) * param_std + param_mean targets = torch.cat(all_targets_norm) * param_std + param_mean - # 오류 계산 errors = preds - targets mae = torch.mean(torch.abs(errors), dim=0) rmse = torch.sqrt(torch.mean(errors**2, dim=0)) @@ -49,7 +46,6 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file print(f"{name:15s}: MAE={mae[i]:7.3f} | RMSE={rmse[i]:7.3f}") print("=" * 50) - # 시각화 if save_plot: fig, axes = plt.subplots(1, 3, figsize=(15, 4)) @@ -75,17 +71,13 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file def load_checkpoint_and_evaluate(test_loader: DataLoader, checkpoint_path: Path, stats_path: Path, report_file: Path): - """체크포인트 로드 및 평가""" - # 체크포인트 로드 ckpt = torch.load(checkpoint_path, map_location="cpu") config = ckpt["config"]["model_args"] - # 모든 인자로 모델 생성 model = XRR1DRegressor(**config) model.load_state_dict(ckpt["model_state_dict"]) model = model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu")) - # 평가 evaluate(model, test_loader, stats_path, report_file) diff --git a/runs/exp04_one_genx/evaluate_with_real_data.py b/runs/exp04_one_genx/evaluate_with_real_data.py index 809b885..f9bd3d8 100644 --- a/runs/exp04_one_genx/evaluate_with_real_data.py +++ b/runs/exp04_one_genx/evaluate_with_real_data.py @@ -7,17 +7,13 @@ from torch.utils.data import DataLoader, TensorDataset -# ------------------------------ -# Helper: 보간하여 모델의 q-grid에 맞추기 -# ------------------------------ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarray) -> np.ndarray: """ - meas_q: (M,) 실제 측정 q - meas_I: (M,) 또는 (N_samples, M) 측정 intensity - target_q: (Q,) 모델 q-grid - 반환: (Q,) 또는 (N_samples, Q) + meas_q: (M,) measured q + meas_I: (M,) or (N_samples, M) measured intensity + target_q: (Q,) model q-grid + Returns: (Q,) or (N_samples, Q) """ - # 만약 여러 샘플이면 first axis가 샘플로 오게 정리 meas_I = np.atleast_2d(meas_I) out = np.empty((meas_I.shape[0], target_q.size), dtype=float) for i, row in enumerate(meas_I): @@ -27,9 +23,6 @@ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarra return out -# ------------------------------ -# Helper: stats 불러와 파라미터 정규화/역정규화 -# ------------------------------ def load_stats(stats_path: Path) -> tuple[np.ndarray, np.ndarray]: st = torch.load(stats_path) param_mean = st["param_mean"].numpy() if isinstance(st["param_mean"], torch.Tensor) else np.array(st["param_mean"]) @@ -45,9 +38,6 @@ def denormalize_params(params_norm: np.ndarray, param_mean: np.ndarray, param_st return params_norm * param_std + param_mean -# ------------------------------ -# Create DataLoader from raw measured arrays -# ------------------------------ def make_dataloader_from_arrays( measured_q: np.ndarray, measured_I: np.ndarray, # shape (N, M) or (M,) @@ -87,9 +77,6 @@ def make_dataloader_from_arrays( return loader, param_mean, param_std -# ------------------------------ -# Main: evaluate and additional visualizations -# ------------------------------ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_prefix: Path, device=None): device = device or (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")) model = model.to(device) @@ -178,34 +165,24 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr return preds, targets, errors -# ------------------------------ -# Example usage block (사용자 환경에 맞게 수정) -# ------------------------------ if __name__ == "__main__": from config import CONFIG from model import XRR1DRegressor - # ----------------------------- - # 경로 설정 - # ----------------------------- # The run main.py trains, not another experiment's folder. exp_dir = CONFIG["base_dir"] / CONFIG["exp_name"] stats_file = exp_dir / "stats.pt" checkpoint_file = exp_dir / "best.pt" out_dir = exp_dir / "eval_real" - # 실제 측정 데이터 디렉토리 real_data_dir = Path(os.environ["DATA"], r"XRR_AI\data\one_layer\real_tester") measured_q = np.load(real_data_dir / "measured_q.npy") # (M,) - measured_I = np.load(real_data_dir / "measured_I.npy") # (N, M) 또는 (M,) + measured_I = np.load(real_data_dir / "measured_I.npy") # (N, M) or (M,) try: measured_params = np.load(real_data_dir / "measured_params.npy") # (N, 3) except FileNotFoundError: measured_params = None - # ----------------------------- - # 모델 불러오기 - # ----------------------------- ckpt = torch.load(checkpoint_file, map_location="cpu") config = ckpt["config"]["model_args"] @@ -213,9 +190,6 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr model.load_state_dict(ckpt["model_state_dict"]) model = model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu")) - # ----------------------------- - # 학습 때 q-grid 재생성 - # ----------------------------- q_points = 200 wavelength = 1.54 # Å tth_min = 1.0 # deg @@ -226,9 +200,6 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr theta = np.deg2rad(tth_deg / 2) q_target = 4 * np.pi / wavelength * np.sin(theta) # Å^-1 - # ----------------------------- - # dataloader 생성 - # ----------------------------- loader, mean, std = make_dataloader_from_arrays( measured_q=measured_q, measured_I=measured_I, @@ -238,7 +209,4 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr batch_size=64, ) - # ----------------------------- - # 평가 - # ----------------------------- preds, targets, errors = evaluate_with_plots(model, loader, stats_file, out_dir) diff --git a/runs/exp04_one_genx/main.py b/runs/exp04_one_genx/main.py index 1152f1a..66e988a 100644 --- a/runs/exp04_one_genx/main.py +++ b/runs/exp04_one_genx/main.py @@ -6,10 +6,8 @@ from evaluate import load_checkpoint_and_evaluate from torch.utils.data import DataLoader -# 재현성 보장 torch.manual_seed(42) np.random.seed(42) -# ========================================================================= def main(): @@ -39,7 +37,6 @@ def main(): print(f"데이터셋: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") - # 모델 생성 from model import XRR1DRegressor model = XRR1DRegressor( @@ -52,7 +49,6 @@ def main(): print(f"모델 파라미터: {sum(p.numel() for p in model.parameters()):,}") - # 학습 from train import Trainer trainer = Trainer( @@ -64,10 +60,8 @@ def main(): weight_decay=CONFIG["training"]["weight_decay"], ) - # 학습 시작 (체크포인트 있으면 자동 로드) trainer.train(CONFIG["training"]["epochs"]) - # 최종 평가 print("\n" + "=" * 50) print("최종 테스트 평가") print("=" * 50) diff --git a/runs/exp04_one_genx/make_one_fake.py b/runs/exp04_one_genx/make_one_fake.py index 8a02128..da30133 100644 --- a/runs/exp04_one_genx/make_one_fake.py +++ b/runs/exp04_one_genx/make_one_fake.py @@ -20,7 +20,6 @@ def generate_dummy_xrr(qs, param: ParamSet): if __name__ == "__main__": from pathlib import Path - # q-space 생성 (당신 config와 동일) wavelen: float = 1.54 # (nm) tth_min: float = 1.0 # degree tth_max: float = 6.0 diff --git a/runs/exp04_one_genx/model.py b/runs/exp04_one_genx/model.py index b986b50..9690de1 100644 --- a/runs/exp04_one_genx/model.py +++ b/runs/exp04_one_genx/model.py @@ -34,7 +34,6 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: # Global pooling self.global_pool = nn.AdaptiveAvgPool1d(1) - # MLP 회귀기 encoder_out_dim = in_ch # Should be 512 for depth=4 self.regressor = nn.Sequential( nn.Linear(encoder_out_dim, mlp_hidden), @@ -42,13 +41,12 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: nn.Dropout(dropout), nn.Linear(mlp_hidden, mlp_hidden // 2), nn.ReLU(inplace=True), - nn.Linear(mlp_hidden // 2, 3), # 3개 파라미터 출력 + nn.Linear(mlp_hidden // 2, 3), ) self._init_weights() def _init_weights(self): - """He 초기화""" for m in self.modules(): if isinstance(m, nn.Conv1d) or isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") @@ -57,7 +55,7 @@ def _init_weights(self): def forward(self, x: torch.Tensor) -> torch.Tensor: if x.dim() == 2: - x = x.unsqueeze(1) # 채널 차원 추가 + x = x.unsqueeze(1) features = self.encoder(x) # (B, ch, seq_len') pooled = self.global_pool(features).squeeze(-1) # (B, ch) diff --git a/runs/exp04_one_genx/simulate.py b/runs/exp04_one_genx/simulate.py index 71686f0..f0dc562 100644 --- a/runs/exp04_one_genx/simulate.py +++ b/runs/exp04_one_genx/simulate.py @@ -8,22 +8,19 @@ def generate_1layer_data(config: dict, h5_file: Path | str): """ - 1-layer XRR 데이터 생성 + Generate 1-layer XRR data. Args: - config: main.py의 CONFIG 딕셔너리 (simulation, paths, param_ranges 포함) + config: CONFIG dict of main.py (simulation, paths, param_ranges) """ print("=== 1-Layer XRR 데이터 생성 시작 ===") - # config에서 모든 파라미터 추출 simulation = config["simulation"] param_ranges = config["param_ranges"] h5_file = Path(h5_file) - # 출력 디렉토리 생성 output_dir = h5_file.parent output_dir.mkdir(exist_ok=True, parents=True) - # q 벡터 생성 q_min = tth2q(simulation["tth_min"], simulation["wavelength"]) q_max = tth2q(simulation["tth_max"], simulation["wavelength"]) qs = np.linspace(q_min, q_max, simulation["q_points"]) diff --git a/runs/exp04_one_genx/train.py b/runs/exp04_one_genx/train.py index a94d84f..032f8af 100644 --- a/runs/exp04_one_genx/train.py +++ b/runs/exp04_one_genx/train.py @@ -63,10 +63,9 @@ def validate(self) -> float: return total_loss / len(self.val_loader.dataset) def train(self, epochs: int, resume_from: str | Path | None = None): - """학습 시작 (이어서 학습 지원)""" + """Train, resuming from a checkpoint when one exists.""" start_epoch = 1 - # 체크포인트 로드 시도 (resume_from 우선, 없으면 best.pt 시도) checkpoint_path = Path(resume_from) if resume_from else self.checkpoint_dir / "best.pt" if checkpoint_path.exists(): @@ -90,7 +89,6 @@ def train(self, epochs: int, resume_from: str | Path | None = None): self.scheduler.step(val_loss) - # 체크포인트 저장 if val_loss < self.best_val_loss: self.best_val_loss = val_loss self.patience_counter = 0 @@ -105,7 +103,6 @@ def train(self, epochs: int, resume_from: str | Path | None = None): break def save_checkpoint(self, filename: str, epoch: int, val_loss: float): - """체크포인트 저장 (학습 상태 포함)""" torch.save( { "epoch": epoch, @@ -123,17 +120,14 @@ def save_checkpoint(self, filename: str, epoch: int, val_loss: float): print(f"Saved checkpoint: {self.checkpoint_dir / filename}") def load_checkpoint(self, filepath: str | Path) -> int: - """체크포인트 로드 (에포크 번호 반환)""" + """Load a checkpoint and return its epoch number.""" checkpoint = torch.load(filepath, map_location=self.device) - # 모델 상태 로드 self.model.load_state_dict(checkpoint["model_state_dict"]) - # 옵티마이저 상태 로드 (없으면 스킵) if "optimizer_state_dict" in checkpoint: self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) - # 스케줄러 상태 로드 (없으면 스킵) if "scheduler_state_dict" in checkpoint: self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"]) diff --git a/runs/exp05_1layer_mask/augmentations.py b/runs/exp05_1layer_mask/augmentations.py index a488367..a0c5481 100644 --- a/runs/exp05_1layer_mask/augmentations.py +++ b/runs/exp05_1layer_mask/augmentations.py @@ -32,12 +32,11 @@ def __call__(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.ndarray if np.random.rand() > self.prob: return q, R - # 1. Intensity Scaling (Global scaling error) - # Simulates errors in I0 normalization + # global intensity scaling error (I0 normalization error) scale_factor = np.random.uniform(1.0 - self.intensity_scale, 1.0 + self.intensity_scale) R_aug = R * scale_factor - # 2. Q-Shifting (Alignment error) + # q alignment error shift = np.random.normal(0, self.q_shift_sigma) q_shifted = q + shift R_aug = np.interp(q, q_shifted, R_aug, left=R_aug[0], right=R_aug[-1]) diff --git a/runs/exp05_1layer_mask/config.py b/runs/exp05_1layer_mask/config.py index db2023e..a5c91a5 100644 --- a/runs/exp05_1layer_mask/config.py +++ b/runs/exp05_1layer_mask/config.py @@ -44,28 +44,24 @@ def save_config(config: dict, file: Path | str): """ - 딕셔너리를 JSON 파일로 저장합니다. - - Path -> str 변환 - - Numpy 타입 -> python native 타입 변환 - - 재귀적 탐색 - - 듣도 보도 못한 타입(Custom Class 등) -> repr() 문자열로 변환하여 저장 (Fallback) + Save a dict as a JSON file. + - Path -> str + - numpy types -> native Python types + - recursive over dicts, lists and tuples + - any other type is stored as its repr() string (fallback) """ def _to_serializable(obj): - # 1. 딕셔너리 재귀 호출 if isinstance(obj, dict): return {k: _to_serializable(v) for k, v in obj.items()} - # 2. 리스트/튜플 재귀 호출 if isinstance(obj, (list, tuple)): return [_to_serializable(v) for v in obj] - # 3. 이미 JSON 처리가 가능한 기본 타입들은 그대로 반환 (중요!) - # 이걸 안 하면 str도 repr() 타서 "'문자열'" 처럼 따옴표가 이중으로 저장됨 + # JSON-native types pass through; otherwise a str would go through repr() and be stored with doubled quotes if isinstance(obj, (str, int, float, bool, type(None))): return obj - # 4. 특수 타입 처리 if isinstance(obj, Path): return str(obj) @@ -75,16 +71,13 @@ def _to_serializable(obj): if isinstance(obj, np.ndarray): return obj.tolist() - # 5. [요청하신 기능] 그 외 모든 알 수 없는 타입은 repr() 문자열로 저장 try: return repr(obj) except Exception: return "" - # 변환 실행 safe_config = _to_serializable(config) - # 경로 생성 및 저장 save_path = Path(file) save_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/runs/exp05_1layer_mask/dataset.py b/runs/exp05_1layer_mask/dataset.py index 9c41c2e..cb6eba6 100644 --- a/runs/exp05_1layer_mask/dataset.py +++ b/runs/exp05_1layer_mask/dataset.py @@ -14,7 +14,6 @@ class XRRPreprocessor: """ def __init__(self, qs: np.ndarray, stats_file: Path | str | None = None, device: torch.device = torch.device("cpu")): - # 1. Set up Master Grid self.target_q = qs self.device = device self.param_mean = None @@ -33,19 +32,17 @@ def process_input(self, q_raw, R_raw): """ Raw Data (Linear q) -> Model Input Tensor (Power q) """ - # 1. 데이터 정제 (NaN 방지) R_raw = np.nan_to_num(R_raw, nan=1e-15, posinf=1e-15, neginf=1e-15) - # 2. Normalize (Max=1.0) -> Log Scale R_max = np.max(R_raw) if R_max <= 0: - R_max = 1.0 # 0 나누기 방지 + R_max = 1.0 # avoid division by zero R_norm = R_raw / R_max R_log = np.log10(np.maximum(R_norm, 1e-15)) - # 3. 오름차순 정렬 (np.interp는 x가 정렬되어 있어야 함) + # np.interp needs ascending x if q_raw[0] > q_raw[-1]: q_raw = q_raw[::-1] R_log = R_log[::-1] @@ -54,14 +51,13 @@ def process_input(self, q_raw, R_raw): R_interp = np.interp(self.target_q, q_raw, R_log, left=padding_val, right=padding_val) - # 모델 그리드(target_q) 중 실측 데이터(q_raw) 범위 안에 있는 것만 유효(1) + # the mask is 1 where the model grid lies inside the measured q range q_valid_mask = (self.target_q >= np.min(q_raw)) & (self.target_q <= np.max(q_raw)) - # 5. Tensor 변환 R_tensor = torch.from_numpy(R_interp.astype(np.float32)) mask_tensor = torch.from_numpy(q_valid_mask.astype(np.float32)) - # (2, N) 형태로 반환 [LogR, Mask] + # (2, N): [LogR, Mask] return torch.stack([R_tensor, mask_tensor], dim=0) def denormalize_params(self, params_norm): @@ -239,18 +235,16 @@ def _apply_crop_augmentation(self, R_raw, q_raw): if slack <= 0: return R_raw, q_raw - # 1. Front Crop: Limit to max 0.04 to protect critical angle (0.03~0.04) - # Crop up to the smaller of 20% of slack or 0.04 + # front crop: at most 0.04, to protect the critical angle (0.03-0.04) max_front_limit = 0.04 front_crop_limit = min(slack * 0.2, max_front_limit) crop_start = np.random.uniform(0, front_crop_limit) - # 2. Back Crop: Allocate remaining slack to back cropping + # back crop: spend the remaining slack remaining_slack = slack - crop_start crop_end = np.random.uniform(0, remaining_slack) - # 3. Slicing new_min = current_min + crop_start new_max = current_max - crop_end diff --git a/runs/exp05_1layer_mask/evaluate.py b/runs/exp05_1layer_mask/evaluate.py index 2d65bc3..34a7069 100644 --- a/runs/exp05_1layer_mask/evaluate.py +++ b/runs/exp05_1layer_mask/evaluate.py @@ -1,4 +1,4 @@ -import argparse # [New] 커맨드라인 인자 처리를 위해 추가 +import argparse from pathlib import Path import matplotlib.gridspec as gridspec @@ -6,8 +6,6 @@ import numpy as np import pandas as pd import torch - -# === User Modules === from config import CONFIG from dataset import XRR1LayerDataset from torch.utils.data import DataLoader @@ -17,9 +15,6 @@ from reflecto_exp.math_utils import powerspace from reflecto_exp.simulate.simul_genx import ParamSet, param2refl -# ------------------------------------------------------------ -# 0. Global Style Settings -# ------------------------------------------------------------ plt.style.use("seaborn-v0_8-paper") plt.rcParams.update( { @@ -34,13 +29,7 @@ ) -# ------------------------------------------------------------ -# 1. Calculation Logic (Metrics & FOM) -# ------------------------------------------------------------ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: - """ - 기본적인 회귀 성능 지표 계산 (MAE, RMSE, MAPE) - """ errors = preds - targets abs_errors = np.abs(errors) @@ -60,8 +49,7 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: def fom_log(ref_exp, ref_calc, epsilon=1e-10): """ - Figure of Merit (Log-scale difference) 계산 - 낮을수록 좋음. + Figure of Merit (log-scale difference); lower is better. """ ref_exp = np.maximum(ref_exp, epsilon) ref_calc = np.maximum(ref_calc, epsilon) @@ -70,12 +58,11 @@ def fom_log(ref_exp, ref_calc, epsilon=1e-10): def calculate_all_foms(qs, preds, targets): """ - AI 예측 파라미터로 물리 시뮬레이션을 돌려 실제 커브와 비교 (FOM 계산) + Simulate curves from the predicted parameters and compare them with the true curves (FOM). """ foms = [] print("\n[Physics Check] Calculating FOM for all test samples...") - # tqdm으로 진행상황 표시 for i in tqdm(range(len(preds)), desc="Simulating Curves"): p_pred = ParamSet(preds[i][0], preds[i][1], preds[i][2]) p_true = ParamSet(targets[i][0], targets[i][1], targets[i][2]) @@ -98,16 +85,12 @@ def generate_results_df(preds, targets, errors, foms, param_names): data[f"{clean}_Pred"] = preds[:, i] data[f"{clean}_Error"] = errors[:, i] - # FOM 컬럼 추가 (foms가 있을 때만) if foms is not None: data["FOM_Log"] = foms return pd.DataFrame(data) -# ------------------------------------------------------------ -# 2. Plotting Functions -# ------------------------------------------------------------ def save_history_plot(history: dict, save_path: Path) -> None: if not save_path or not history: return @@ -155,7 +138,6 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa y_pred = df[f"{clean}_Pred"].values err = df[f"{clean}_Error"].values - # 1. Hexbin Scatter ax1 = fig.add_subplot(gs[0, i]) min_v = min(y_true.min(), y_pred.min()) max_v = max(y_true.max(), y_pred.max()) @@ -166,7 +148,6 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa ax1.text(0.05, 0.95, f"$R^2 = {corr**2:.3f}$", transform=ax1.transAxes, bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.9}) ax1.set_title(f"{clean}: Pred vs True", fontweight="bold") - # 2. Residuals Plot ax2 = fig.add_subplot(gs[1, i]) ax2.scatter(y_true, err, alpha=0.4, s=15, color=colors[i % len(colors)], edgecolor="w", linewidth=0.5) ax2.axhline(0, color="black", linestyle="--") @@ -175,7 +156,6 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa ax2.axhline(-1.96 * std_err, color="red", linestyle=":", alpha=0.6) ax2.set_title(f"{clean}: Residuals", fontweight="bold") - # 3. CDF ax3 = fig.add_subplot(gs[2, i]) sorted_abs_err = np.sort(np.abs(err)) p = 1.0 * np.arange(len(err)) / (len(err) - 1) @@ -200,7 +180,6 @@ def save_fom_report(foms: np.ndarray, save_path: Path): fig = plt.figure(figsize=(10, 6), layout="constrained") gs = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1.5, 1]) - # 1. Histogram ax1 = fig.add_subplot(gs[0]) cut_off = np.percentile(foms, 99) foms_view = foms[foms <= cut_off] @@ -216,7 +195,6 @@ def save_fom_report(foms: np.ndarray, save_path: Path): ax1.axvline(median_val, color="orange", linestyle=":", linewidth=2, label=f"Median: {median_val:.4f}") ax1.legend() - # 2. Stats Text ax2 = fig.add_subplot(gs[1]) ax2.axis("off") @@ -255,10 +233,8 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): if not save_path or foms is None: return - # FOM 기준으로 정렬 sorted_indices = np.argsort(foms) - # Best 2, Random 2, Worst 2 선택 indices = np.concatenate([sorted_indices[:2], np.random.choice(sorted_indices[2:-2], 2, replace=False), sorted_indices[-2:]]) titles = ["Best Case 1", "Best Case 2", "Random Case 1", "Random Case 2", "Worst Case 1", "Worst Case 2"] @@ -303,9 +279,6 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): print(f"Saved Curve Comparison: {save_path}") -# ------------------------------------------------------------ -# 3. Main Evaluation Pipeline -# ------------------------------------------------------------ def evaluate_pipeline( test_loader: DataLoader, checkpoint_path: Path, @@ -326,7 +299,6 @@ def evaluate_pipeline( else: print(">> Option: Physics-based FOM calculation is DISABLED.") - # 1. Load Stats & Model stats = torch.load(stats_path, map_location="cpu") param_mean = stats["param_mean"] param_std = stats["param_std"] @@ -341,7 +313,6 @@ def evaluate_pipeline( print(f"Model loaded: {checkpoint_path.name}") - # 2. Run Inference preds_list, targets_list = [], [] with torch.no_grad(): for X, y in tqdm(test_loader, desc="Inference"): @@ -353,20 +324,16 @@ def evaluate_pipeline( preds_norm = torch.cat(preds_list) targets_norm = torch.cat(targets_list) - # 3. Denormalize preds_real = (preds_norm * param_std + param_mean).numpy() targets_real = (targets_norm * param_std + param_mean).numpy() - # 4. Calculate Physics FOM (Optional) foms = None if calc_physics_fom: foms = calculate_all_foms(qs, preds_real, targets_real) - # 5. Calculate Standard Metrics metrics = calculate_metrics(preds_real, targets_real) param_names = ["Thickness (Å)", "Roughness (Å)", "SLD (10⁻⁶ Å⁻²)"] - # 6. Console Output Summary print("\n" + "=" * 65) print(f"{'Parameter':<20} | {'MAE':<10} | {'RMSE':<10} | {'MAPE (%)':<10}") print("-" * 65) @@ -378,7 +345,6 @@ def evaluate_pipeline( print(f"Mean FOM: {np.mean(foms):.5f} | Median FOM: {np.median(foms):.5f}") print("=" * 65) - # 7. Save Outputs output_dir.mkdir(parents=True, exist_ok=True) # CSV Report @@ -400,11 +366,7 @@ def evaluate_pipeline( print("Skipping physics-based plots (FOM Stats, Curve Comparison) as FOM was not calculated.") -# ------------------------------------------------------------ -# 4. Main Entry -# ------------------------------------------------------------ def main(): - # Argument Parser 추가 parser = argparse.ArgumentParser(description="Evaluate XRR AI Model") parser.add_argument("--fom", action="store_true", help="Calculate physics-based FOM (Slow!)") args = parser.parse_args() diff --git a/runs/exp05_1layer_mask/evaluate2.py b/runs/exp05_1layer_mask/evaluate2.py index 98092ca..6c37c74 100644 --- a/runs/exp05_1layer_mask/evaluate2.py +++ b/runs/exp05_1layer_mask/evaluate2.py @@ -5,18 +5,15 @@ import numpy as np import pandas as pd import torch - -# === User Modules === from config import CONFIG from dataset import XRR1LayerDataset from torch.utils.data import DataLoader -from tqdm import tqdm # 진행상황 표시용 +from tqdm import tqdm from xrr_model import XRR1DRegressor from reflecto_exp.math_utils import powerspace from reflecto_exp.simulate.simul_genx import ParamSet, param2refl -# 스타일 설정 plt.style.use("seaborn-v0_8-paper") plt.rcParams.update( { @@ -31,15 +28,12 @@ ) -# ------------------------------------------------------------ -# 0. FOM Calculation Logic -# ------------------------------------------------------------ def fom_log(ref_exp, ref_calc, epsilon=1e-10): """ Calculates Figure of Merit (log-scale difference). Lower is better. """ - # log(0) 방지 + # avoid log(0) ref_exp = np.maximum(ref_exp, epsilon) ref_calc = np.maximum(ref_calc, epsilon) return np.mean(np.abs(np.log10(ref_exp) - np.log10(ref_calc))) @@ -47,12 +41,11 @@ def fom_log(ref_exp, ref_calc, epsilon=1e-10): def calculate_all_foms(qs, preds, targets): """ - 전체 데이터셋에 대해 Curve를 생성하고 FOM을 계산합니다. + Build a curve for every sample and compute its FOM. """ foms = [] print("Calculating FOM for all test samples (Physics Simulation)...") - # 배치 처리가 가능하다면 더 빠르겠지만, 안전하게 루프로 처리 for i in tqdm(range(len(preds))): p_pred = ParamSet(preds[i][0], preds[i][1], preds[i][2]) p_true = ParamSet(targets[i][0], targets[i][1], targets[i][2]) @@ -67,9 +60,6 @@ def calculate_all_foms(qs, preds, targets): return np.array(foms) -# ------------------------------------------------------------ -# 1. Metrics & Data Utils (Existing) -# ------------------------------------------------------------ def calculate_metrics(preds, targets): errors = preds - targets abs_errors = np.abs(errors) @@ -88,17 +78,13 @@ def generate_results_df(preds, targets, errors, foms, param_names): data[f"{clean}_Pred"] = preds[:, i] data[f"{clean}_Error"] = errors[:, i] - # FOM 컬럼 추가 data["FOM_Log"] = foms return pd.DataFrame(data) -# ------------------------------------------------------------ -# 2. FOM Distribution Plot (New!) -# ------------------------------------------------------------ def save_fom_report(foms: np.ndarray, save_path: Path): """ - FOM 분포를 히스토그램과 통계로 보여주는 보고서 + Histogram and summary statistics of the FOM distribution. """ if not save_path: return @@ -106,9 +92,8 @@ def save_fom_report(foms: np.ndarray, save_path: Path): fig = plt.figure(figsize=(10, 6), layout="constrained") gs = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1.5, 1]) - # 1. Histogram & KDE ax1 = fig.add_subplot(gs[0]) - # 이상치 제외하고 보여주기 위해 99% quantile까지만 자름 (그래프 가독성) + # clip at the 99th percentile so outliers do not stretch the histogram cut_off = np.percentile(foms, 99) foms_view = foms[foms <= cut_off] @@ -117,14 +102,12 @@ def save_fom_report(foms: np.ndarray, save_path: Path): ax1.set_xlabel("FOM Value (Lower is Better)") ax1.set_ylabel("Density") - # 평균, 중위수 표시 mean_val = np.mean(foms) median_val = np.median(foms) ax1.axvline(mean_val, color="red", linestyle="--", linewidth=1.5, label=f"Mean: {mean_val:.4f}") ax1.axvline(median_val, color="orange", linestyle=":", linewidth=2, label=f"Median: {median_val:.4f}") ax1.legend() - # 2. Summary Stats Text Box ax2 = fig.add_subplot(gs[1]) ax2.axis("off") @@ -159,14 +142,10 @@ def save_fom_report(foms: np.ndarray, save_path: Path): print(f"Saved FOM Report: {save_path}") -# ------------------------------------------------------------ -# 3. Curve Reconstruction Plot (Updated with FOM) -# ------------------------------------------------------------ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): if not save_path: return - # FOM 기준으로 Best/Worst 정렬 (MSE 대신 FOM 사용이 더 설득력 있음) sorted_indices = np.argsort(foms) indices = np.concatenate( @@ -187,7 +166,7 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): t_true = targets[idx] t_pred = preds[idx] - fom_val = foms[idx] # 해당 샘플의 FOM + fom_val = foms[idx] p_true = ParamSet(t_true[0], t_true[1], t_true[2]) p_pred = ParamSet(t_pred[0], t_pred[1], t_pred[2]) @@ -198,10 +177,8 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): ax.semilogy(qs, curve_true, "k-", lw=3, alpha=0.6, label="Ground Truth") ax.semilogy(qs, curve_pred, "r--", lw=2, label="AI Prediction") - # 제목에 FOM 표시 (핵심) title_str = f"{titles[i]}\nFOM: {fom_val:.5f} | Thick Err: {abs(t_true[0] - t_pred[0]):.1f}Å" - # FOM이 좋으면(낮으면) 초록색, 나쁘면 빨간색 텍스트 color = "darkgreen" if fom_val < 0.05 else "black" if i >= 4: color = "firebrick" # Worst cases @@ -220,9 +197,6 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): print(f"Saved Curve Proof: {save_path}") -# ------------------------------------------------------------ -# 4. Investor Report (Metrics - Same as before) -# ------------------------------------------------------------ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_path: Path): if not save_path: return @@ -272,9 +246,6 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa print(f"Saved Metric Report: {save_path}") -# ------------------------------------------------------------ -# 5. Pipeline -# ------------------------------------------------------------ def evaluate_pipeline(test_loader, checkpoint_path, stats_path, qs, output_dir): if not checkpoint_path.exists(): @@ -310,25 +281,19 @@ def evaluate_pipeline(test_loader, checkpoint_path, stats_path, qs, output_dir): preds_real = (preds_norm * param_std + param_mean).numpy() targets_real = (targets_norm * param_std + param_mean).numpy() - # --- FOM Calculation (NEW) --- - # 모든 샘플에 대해 시뮬레이션 및 FOM 계산 foms = calculate_all_foms(qs, preds_real, targets_real) # Metrics metrics = calculate_metrics(preds_real, targets_real) param_names = ["Thickness (Å)", "Roughness (Å)", "SLD (10⁻⁶ Å⁻²)"] - # 1. Save CSV (with FOM) df = generate_results_df(preds_real, targets_real, metrics["errors"], foms, param_names) df.to_csv(output_dir / "evaluation_results_with_fom.csv", index=False) - # 2. Plots save_investor_report_plots(df, param_names, output_dir / "investor_metrics_report.png") - # 3. FOM Report (NEW Graph) save_fom_report(foms, output_dir / "investor_fom_stats.png") - # 4. Curve Proof with FOM Titles (Updated) save_curve_comparison(qs, preds_real, targets_real, foms, output_dir / "investor_curve_proof.png") # Console Output @@ -340,9 +305,6 @@ def evaluate_pipeline(test_loader, checkpoint_path, stats_path, qs, output_dir): print("=" * 60) -# ------------------------------------------------------------ -# 6. Main -# ------------------------------------------------------------ def main(): base = Path(CONFIG["base_dir"]) exp_dir = base / CONFIG["exp_name"] diff --git a/runs/exp05_1layer_mask/fitting_engine.py b/runs/exp05_1layer_mask/fitting_engine.py index 08d7602..2a63f80 100644 --- a/runs/exp05_1layer_mask/fitting_engine.py +++ b/runs/exp05_1layer_mask/fitting_engine.py @@ -30,7 +30,6 @@ def _build_model(self): model = Model() model.data = DataList([ds]) - # 초기값 sio_d, sio_sig, sio_sld = 15.0, 3.0, 18.8 script = rf""" @@ -127,8 +126,6 @@ def run(self, verbose=True): pars = Parameters() model = self.model - # --- Parameters --- - # 1. Material Params p_f_d = pars.append("v.set_f_d", model) p_f_d.min = 1.0 p_f_d.max = self.init_d * 2 @@ -152,37 +149,32 @@ def run(self, verbose=True): p_s_sld.min = 10.0 p_s_sld.max = 23.0 - # 2. Instrument Params (I0 + Footprint) p_i0 = pars.append("v.set_i0", model) p_i0.value = 1.0 p_i0.min = 0.1 p_i0.max = 5.0 - # [추가] 샘플 길이 피팅 (저각도 모양 결정) p_slen = pars.append("v.set_s_len", model) - p_slen.value = 10.0 # 초기값 10mm - p_slen.min = 2.0 # 최소 2mm - p_slen.max = 50.0 # 최대 50mm + p_slen.value = 10.0 # mm + p_slen.min = 2.0 + p_slen.max = 50.0 - # [추가] 빔 폭 (보통 고정하거나 미세 조정) p_beam = pars.append("v.set_beam_w", model) - p_beam.value = 0.1 # 초기값 0.1mm (슬릿 크기) + p_beam.value = 0.1 # mm, slit size p_beam.min = 0.01 p_beam.max = 0.5 - p_beam.fit = False # 일단 고정 (원하면 True) + p_beam.fit = False model.parameters = pars - # --- Step 1: I0 & Footprint Fitting (Linear) --- if verbose: print("\n[GenX] Step 1: Fitting I0 & Sample Length...") model.set_fom_func(fom_funcs.diff) # Linear Scale - # I0와 Sample Length는 서로 스케일에 영향을 주므로 같이 피팅 + # I0 and sample length both scale the curve, so they are fitted together p_i0.fit = True p_slen.fit = True - # 나머지는 끔 p_f_d.fit = False p_f_sig.fit = False p_f_sld.fit = False @@ -195,12 +187,10 @@ def run(self, verbose=True): if verbose: print(f" -> I0: {p_i0.value:.3f}, Sample Len: {p_slen.value:.2f} mm") - # --- Step 2: Full Fitting (Log) --- if verbose: print("[GenX] Step 2: Fitting All Params (Log)...") model.set_fom_func(fom_funcs.log) - # 미세조정 위해 I0, SampleLen 켜둠 (원하면 꺼도 됨) p_i0.fit = True p_slen.fit = True diff --git a/runs/exp05_1layer_mask/inference.py b/runs/exp05_1layer_mask/inference.py index 7f4c39a..69756b0 100644 --- a/runs/exp05_1layer_mask/inference.py +++ b/runs/exp05_1layer_mask/inference.py @@ -7,7 +7,7 @@ from dataset import XRRPreprocessor from xrr_model import XRR1DRegressor -from reflecto_exp.math_utils import powerspace # [필수] powerspace 임포트 +from reflecto_exp.math_utils import powerspace class XRRInferenceEngine: @@ -15,7 +15,6 @@ def __init__(self, exp_dir=None): self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[Inference] Device: {self.device}") - # 1. 경로 설정 if exp_dir is None: exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] else: @@ -33,7 +32,7 @@ def __init__(self, exp_dir=None): print("[Inference] Warning: config.json not found. Fallback to current global CONFIG.") self.train_config = CONFIG - # 2. Master Grid 설정 (학습 때와 동일해야 함!) + # must match the training grid sim_conf = self.train_config["simulation"] q_min = sim_conf["q_min"] q_max = sim_conf["q_max"] @@ -42,15 +41,11 @@ def __init__(self, exp_dir=None): print(f"[Inference] Grid Generation: Power={power}, Points={n_points}") - # 모델이 바라보는 세상(Grid)을 학습 때와 똑같이 만듭니다. self.target_qs = powerspace(q_min, q_max, n_points, power=power).astype(np.float32) - # 3. 전처리기 초기화 - # 여기서 target_qs를 넘겨주면, process_input에서 - # 들어오는 모든 데이터(등간격)를 이 target_qs(비선형)에 맞춰 내삽합니다. + # process_input interpolates the incoming evenly spaced data onto target_qs (a nonlinear grid) self.processor = XRRPreprocessor(qs=self.target_qs, stats_file=self.stats_file, device=self.device) - # 4. 모델 로드 self._load_model() def _load_model(self): @@ -75,19 +70,15 @@ def _load_model(self): def predict(self, q_raw, R_raw): """ - q_raw: 실측 데이터의 q (등간격이어도 상관없음) - R_raw: 실측 데이터의 R + q_raw: q of the measured data (need not be evenly spaced) + R_raw: measured R """ - # 1. 전처리 (Resampling) - # 내부적으로 np.interp(self.target_qs, q_raw, R_raw)가 실행됩니다. - # 즉, 등간격 데이터를 모델이 아는 Powerspace 간격으로 변환합니다. + # np.interp resamples evenly spaced data onto the model's powerspace grid x = self.processor.process_input(q_raw, R_raw).unsqueeze(0).to(self.device) - # 2. 추론 with torch.no_grad(): y_pred_norm = self.model(x).squeeze(0) - # 3. 역정규화 y_pred = self.processor.denormalize_params(y_pred_norm) return y_pred diff --git a/runs/exp05_1layer_mask/inference_pipeline.py b/runs/exp05_1layer_mask/inference_pipeline.py index 65c9894..ed323bb 100644 --- a/runs/exp05_1layer_mask/inference_pipeline.py +++ b/runs/exp05_1layer_mask/inference_pipeline.py @@ -12,25 +12,25 @@ def run_xrr_analysis( data_path: str | Path, weights_dir: str | Path, inference_engine: XRRInferenceEngine | None = None, verbose: bool = True, show_plot: bool = True ) -> dict: """ - 단일 XRR 데이터 파일에 대해 [로드 -> NN 추론 -> GenX 피팅] 파이프라인을 실행합니다. + Run the [load -> NN inference -> GenX fit] pipeline on one XRR data file. Args: - data_path (str | Path): 분석할 .dat 파일 경로 - weights_dir (str | Path): 학습된 NN 모델 폴더 경로 + data_path (str | Path): path of the .dat file to analyze + weights_dir (str | Path): folder of the trained NN model inference_engine (XRRInferenceEngine, optional): - 이미 로드된 추론 엔진 객체. None이면 함수 내부에서 새로 로드합니다. - (반복 호출 시 엔진을 미리 로드해서 넘겨주면 속도가 훨씬 빠릅니다) - verbose (bool): 진행 상황 출력 여부 - show_plot (bool): 결과 그래프 표시 여부 + Already loaded engine. Loaded inside the function when None. + (Passing a preloaded engine is much faster for repeated calls.) + verbose (bool): print progress + show_plot (bool): show the result plot Returns: - dict: 분석 결과 { - "nn_preds": (d, sig, sld), # NN 예측값 - "final_params": dict, # GenX 최종 피팅 파라미터 - "fitter": GenXFitter 객체, # 피팅 객체 (시뮬레이션 데이터 포함) - "q": np.array, # q 데이터 - "R_measured": np.array, # 측정된 R - "R_fit": np.array # 피팅된 R + dict: analysis result { + "nn_preds": (d, sig, sld), # NN prediction + "final_params": dict, # final GenX fit parameters + "fitter": GenXFitter, # fitter object (holds the simulated data) + "q": np.array, # q data + "R_measured": np.array, # measured R + "R_fit": np.array # fitted R } """ data_path = Path(data_path) @@ -41,25 +41,16 @@ def run_xrr_analysis( print(f"🚀 XRR Analysis Pipeline: {data_path.name}") print("=" * 60) - # --------------------------------------------------------- - # 1. 데이터 로드 (Data Loading) - # --------------------------------------------------------- if not data_path.exists(): raise FileNotFoundError(f"[Error] 데이터 파일을 찾을 수 없습니다: {data_path}") - # Pandas Series 문제 방지를 위해 np.array로 명시적 변환 tths, R_raw = load_xrr_dat(data_path) - # tth -> q 변환 q_raw = tth2q(tths) if verbose: print(f"[Data] Loaded {len(q_raw)} points.") - # --------------------------------------------------------- - # 2. NN 초기값 예측 (Neural Network Inference) - # --------------------------------------------------------- - # 엔진이 주입되지 않았으면 새로 로드 (단발성 실행용) if inference_engine is None: if verbose: print("[Init] Loading Inference Engine...") @@ -76,23 +67,15 @@ def run_xrr_analysis( print(f" Roughness : {pred_sig:.2f} Å") print(f" SLD : {pred_sld:.3f} (10⁻⁶ Å⁻²)") - # GenXFitter용 파라미터 객체 생성 nn_initial_params = ParamSet(pred_d, pred_sig, pred_sld) - # --------------------------------------------------------- - # 3. GenX 정밀 피팅 (GenX Refinement) - # --------------------------------------------------------- if verbose: print("\n[Step 2] GenX Fitting (Optimization)...") fitter = GenXFitter(q_raw, R_raw, nn_initial_params) - # 피팅 실행 final_results = fitter.run(verbose=verbose) - # --------------------------------------------------------- - # 4. 결과 정리 및 시각화 - # --------------------------------------------------------- if verbose: print("\n" + "-" * 40) print("FINAL ANALYSIS RESULT") @@ -104,7 +87,6 @@ def run_xrr_analysis( if show_plot: fitter.plot() - # 결과 반환 return { "nn_preds": (pred_d, pred_sig, pred_sld), "final_params": final_results, @@ -115,19 +97,13 @@ def run_xrr_analysis( } -# ========================================================= -# 사용 예시 (Main) -# ========================================================= def main(): - # 설정 target_file = Path(r"C:\Users\IsaacYong\Documents\카카오톡 받은 파일\#1.dat") weights_path = Path(r"D:\data\XRR_AI\one_layer\test") try: - # 함수 호출 result = run_xrr_analysis(data_path=target_file, weights_dir=weights_path, verbose=True, show_plot=True) - # 결과 데이터 활용 예시 print(f"최종 피팅된 두께: {result['final_params']['f_d']:.2f} Å") except Exception as e: diff --git a/runs/exp05_1layer_mask/inspect_data.py b/runs/exp05_1layer_mask/inspect_data.py index 24f6575..dbef020 100644 --- a/runs/exp05_1layer_mask/inspect_data.py +++ b/runs/exp05_1layer_mask/inspect_data.py @@ -6,7 +6,6 @@ import numpy as np import torch -# 모듈 경로 설정 current_dir = Path(__file__).parent sys.path.append(str(current_dir)) @@ -43,7 +42,6 @@ def inspect_statistics(stats_file): def inspect_preprocessing(h5_file, stats_file, sample_idx=0): print(f"\n=== [2] Preprocessing Check (Sample {sample_idx}) ===") - # 1. Load Raw Data with h5py.File(h5_file, "r") as hf: q_raw = hf["q"][:] R_raw = hf["R"][sample_idx] @@ -54,17 +52,13 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): sld = hf["sld"][sample_idx] print(f"Target Params: d={d}, sig={sig}, sld={sld}") - # 2. Setup Preprocessor - # Config에서 설정 가져오기 sim_conf = CONFIG["simulation"] - # Power Grid 생성 (학습 때와 동일해야 함) + # must match the training grid target_qs = powerspace(sim_conf["q_min"], sim_conf["q_max"], sim_conf["q_points"], power=sim_conf["power"]).astype(np.float32) processor = XRRPreprocessor(qs=target_qs, stats_file=stats_file) - # 3. Step-by-Step Preprocessing & Visualization - # Step A: Log & Normalize (Max=1) R_max = np.max(R_raw) R_norm = R_raw / (R_max + 1e-15) @@ -72,7 +66,7 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): # Step B: Interpolation (Linear q -> Power q) # Padding Check: -15.0 vs 0.0 - padding_val = -15.0 # 우리가 원하는 바닥값 + padding_val = -15.0 # floor value for padded points _R_interp = np.interp(target_qs, q_raw, R_log, left=padding_val, right=padding_val) # Step C: Masking @@ -83,7 +77,6 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): model_input_R = input_tensor[0].numpy() model_input_M = input_tensor[1].numpy() - # --- Visualization --- plt.figure(figsize=(15, 10)) # Plot 1: Raw Linear Data @@ -107,7 +100,6 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): plt.grid(True, alpha=0.3) # Check for "The Cliff" (Zero Padding Issue) - # 마스크가 0인 구간(데이터 밖)의 값이 -15 근처인지, 0인지 확인 out_of_range_indices = np.where(model_input_M == 0)[0] if len(out_of_range_indices) > 0: bg_val = np.mean(model_input_R[out_of_range_indices]) diff --git a/runs/exp05_1layer_mask/main.py b/runs/exp05_1layer_mask/main.py index 5442091..3e28a6e 100644 --- a/runs/exp05_1layer_mask/main.py +++ b/runs/exp05_1layer_mask/main.py @@ -96,7 +96,6 @@ def main(): checkpoint_file = exp_dir / "best.pt" last_checkpoint_file = exp_dir / "last.pt" - # [제거됨] evaluate.py가 내부적으로 경로를 생성하므로 여기서 정의할 필요 없음 # report_file_img = exp_dir / "error_distribution.png" # report_file_csv = exp_dir / "evaluation_results.csv" # report_history_img = exp_dir / "training_history.png" diff --git a/runs/exp05_1layer_mask/simulate.py b/runs/exp05_1layer_mask/simulate.py index f6c00f0..0567113 100644 --- a/runs/exp05_1layer_mask/simulate.py +++ b/runs/exp05_1layer_mask/simulate.py @@ -7,18 +7,16 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): """ - 1-layer XRR 데이터 생성 + Generate 1-layer XRR data. Args: - config: main.py의 CONFIG 딕셔너리 (simulation, paths, param_ranges 포함) + config: CONFIG dict of main.py (simulation, paths, param_ranges) """ print("=== 1-Layer XRR 데이터 생성 시작 ===") - # config에서 모든 파라미터 추출 simulation = config["simulation"] param_ranges = config["param_ranges"] h5_file = Path(h5_file) - # 출력 디렉토리 생성 output_dir = h5_file.parent output_dir.mkdir(exist_ok=True, parents=True) diff --git a/runs/exp05_1layer_mask/train.py b/runs/exp05_1layer_mask/train.py index fa693e5..d1dd475 100644 --- a/runs/exp05_1layer_mask/train.py +++ b/runs/exp05_1layer_mask/train.py @@ -42,7 +42,7 @@ def __init__( self.criterion = nn.MSELoss() # Scheduler: Reduce Learning Rate when validation loss plateaus - # Note: 'verbose' argument is deprecated in newer PyTorch versions, removed here. + # the scheduler's 'verbose' argument is deprecated in newer PyTorch versions, so it is omitted self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=5, factor=0.5) # Training State diff --git a/runs/exp05_1layer_mask/tune.py b/runs/exp05_1layer_mask/tune.py index bf5a4dc..43d565f 100644 --- a/runs/exp05_1layer_mask/tune.py +++ b/runs/exp05_1layer_mask/tune.py @@ -13,9 +13,6 @@ from tqdm import tqdm from xrr_model import XRR1DRegressor -# --------------------------------------------------------- -# 1. Settings -# --------------------------------------------------------- N_TRIALS = 50 # Total number of trials N_EPOCHS_PER_TRIAL = 15 # Epochs per trial (keep it lower than full training) STUDY_NAME = "xrr_optimization" @@ -47,7 +44,7 @@ def get_dataloaders(batch_size): train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") - # Important: pin_memory=False for Windows stability if needed + # pin_memory=False if needed for Windows stability train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True) val_loader = DataLoader(val_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True) @@ -55,9 +52,6 @@ def get_dataloaders(batch_size): def objective(trial): - # --------------------------------------------------------- - # 2. Define Hyperparameter Search Space - # --------------------------------------------------------- # Architecture n_channels = trial.suggest_categorical("n_channels", [32, 64]) depth = trial.suggest_int("depth", 3, 5) @@ -69,9 +63,6 @@ def objective(trial): weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-3, log=True) batch_size = trial.suggest_categorical("batch_size", [32, 64, 128]) # Keep max low to avoid OOM - # --------------------------------------------------------- - # 3. Setup (Model, Data, Optimizer) - # --------------------------------------------------------- device = torch.device("cuda" if torch.cuda.is_available() else "cpu") try: @@ -94,12 +85,8 @@ def objective(trial): criterion = nn.MSELoss() scaler = torch.amp.GradScaler("cuda") - # --------------------------------------------------------- - # 4. Training Loop (Simplified for Tuning) - # --------------------------------------------------------- pbar = tqdm(range(N_EPOCHS_PER_TRIAL)) for epoch in pbar: - # --- Train --- model.train() for inputs, targets in train_loader: inputs, targets = inputs.to(device), targets.to(device) @@ -116,7 +103,6 @@ def objective(trial): scaler.step(optimizer) scaler.update() - # --- Validate --- model.eval() val_loss = 0.0 with torch.no_grad(): @@ -128,7 +114,6 @@ def objective(trial): avg_val_loss = val_loss / len(val_loader) - # --- Pruning (Early Stopping) --- # Report current result to Optuna trial.report(avg_val_loss, epoch) diff --git a/runs/exp05_1layer_mask/xrr_model.py b/runs/exp05_1layer_mask/xrr_model.py index 18d61b7..a6efea5 100644 --- a/runs/exp05_1layer_mask/xrr_model.py +++ b/runs/exp05_1layer_mask/xrr_model.py @@ -36,9 +36,7 @@ def __init__( "dropout": dropout, } - # --------------------------------------------------------- # 1D CNN Encoder - # --------------------------------------------------------- layers = [] in_ch = input_channels @@ -61,9 +59,7 @@ def __init__( # Summarizes features across the entire q-range self.global_pool = nn.AdaptiveAvgPool1d(1) - # --------------------------------------------------------- # MLP Regressor - # --------------------------------------------------------- encoder_out_dim = in_ch # The channel count of the last conv layer self.regressor = nn.Sequential( nn.Linear(encoder_out_dim, mlp_hidden), @@ -71,7 +67,7 @@ def __init__( nn.Dropout(dropout), nn.Linear(mlp_hidden, mlp_hidden // 2), nn.ReLU(inplace=True), - nn.Linear(mlp_hidden // 2, output_dim), # 출력: Thickness, Roughness, SLD + nn.Linear(mlp_hidden // 2, output_dim), # thickness, roughness, SLD ) self._init_weights() @@ -97,11 +93,8 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: Returns: Predicted normalized parameters of shape (Batch, output_dim). """ - # 1. Extract Features features = self.encoder(x) # Output: (B, Last_Ch, Reduced_Len) - # 2. Global Pooling pooled = self.global_pool(features).squeeze(-1) # Output: (B, Last_Ch) - # 3. Regression return self.regressor(pooled) diff --git a/runs/exp06_1lay_6out/config.py b/runs/exp06_1lay_6out/config.py index 5bde772..33e088b 100644 --- a/runs/exp06_1lay_6out/config.py +++ b/runs/exp06_1lay_6out/config.py @@ -41,28 +41,24 @@ def save_config(config: dict, file: Path | str): """ - 딕셔너리를 JSON 파일로 저장합니다. - - Path -> str 변환 - - Numpy 타입 -> python native 타입 변환 - - 재귀적 탐색 - - 듣도 보도 못한 타입(Custom Class 등) -> repr() 문자열로 변환하여 저장 (Fallback) + Save a dict as a JSON file. + - Path -> str + - numpy types -> native Python types + - recursive over dicts, lists and tuples + - any other type is stored as its repr() string (fallback) """ def _to_serializable(obj): - # 1. 딕셔너리 재귀 호출 if isinstance(obj, dict): return {k: _to_serializable(v) for k, v in obj.items()} - # 2. 리스트/튜플 재귀 호출 if isinstance(obj, (list, tuple)): return [_to_serializable(v) for v in obj] - # 3. 이미 JSON 처리가 가능한 기본 타입들은 그대로 반환 (중요!) - # 이걸 안 하면 str도 repr() 타서 "'문자열'" 처럼 따옴표가 이중으로 저장됨 + # JSON-native types pass through; otherwise a str would go through repr() and be stored with doubled quotes if isinstance(obj, (str, int, float, bool, type(None))): return obj - # 4. 특수 타입 처리 if isinstance(obj, Path): return str(obj) @@ -72,16 +68,13 @@ def _to_serializable(obj): if isinstance(obj, np.ndarray): return obj.tolist() - # 5. [요청하신 기능] 그 외 모든 알 수 없는 타입은 repr() 문자열로 저장 try: return repr(obj) except Exception: return "" - # 변환 실행 safe_config = _to_serializable(config) - # 경로 생성 및 저장 save_path = Path(file) save_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/runs/exp06_1lay_6out/dataset.py b/runs/exp06_1lay_6out/dataset.py index 643cf03..c731b27 100644 --- a/runs/exp06_1lay_6out/dataset.py +++ b/runs/exp06_1lay_6out/dataset.py @@ -15,7 +15,6 @@ class XRRPreprocessor: """ def __init__(self, qs: np.ndarray, stats_file: Path | str | None = None, device: torch.device = torch.device("cpu")): - # 1. Set up Master Grid self.target_q = qs self.device = device self.param_mean = None @@ -245,18 +244,16 @@ def _apply_augmentation(self, R_raw, q_raw): if slack <= 0: return R_raw, q_raw - # 1. Front Crop: Limit to max 0.04 to protect critical angle (0.03~0.04) - # Crop up to the smaller of 20% of slack or 0.04 + # front crop: at most 0.04, to protect the critical angle (0.03-0.04) max_front_limit = 0.04 front_crop_limit = min(slack * 0.2, max_front_limit) crop_start = np.random.uniform(0, front_crop_limit) - # 2. Back Crop: Allocate remaining slack to back cropping + # back crop: spend the remaining slack remaining_slack = slack - crop_start crop_end = np.random.uniform(0, remaining_slack) - # 3. Slicing new_min = current_min + crop_start new_max = current_max - crop_end diff --git a/runs/exp06_1lay_6out/evaluate.py b/runs/exp06_1lay_6out/evaluate.py index 30cab4d..2bc4e40 100644 --- a/runs/exp06_1lay_6out/evaluate.py +++ b/runs/exp06_1lay_6out/evaluate.py @@ -88,7 +88,6 @@ def save_results_plot(df: pd.DataFrame, param_names: list[str], save_path: Path) y_pred = df[col_pred].values err_data = df[col_err].values - # 1. Scatter Plot ax_scatter = axes[0, i] min_val = min(np.min(y_true), np.min(y_pred)) max_val = max(np.max(y_true), np.max(y_pred)) @@ -102,7 +101,6 @@ def save_results_plot(df: pd.DataFrame, param_names: list[str], save_path: Path) ax_scatter.legend() ax_scatter.grid(True, alpha=0.3) - # 2. Error Histogram ax_hist = axes[1, i] mu = np.mean(err_data) sigma = np.std(err_data) @@ -134,7 +132,6 @@ def save_history_plot(history: dict, save_path: Path) -> None: fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - # 1. Loss Curve ax1.plot(epochs, history["train"], label="Train Loss", color="blue", linewidth=2) ax1.plot(epochs, history["val"], label="Val Loss", color="orange", linewidth=2) ax1.set_xlabel("Epochs") @@ -143,7 +140,6 @@ def save_history_plot(history: dict, save_path: Path) -> None: ax1.legend() ax1.grid(True, alpha=0.3) - # 2. Learning Rate Curve if "lr" in history and len(history["lr"]) > 0: ax2.plot(epochs, history["lr"], label="Learning Rate", color="green", linestyle="--") ax2.set_xlabel("Epochs") @@ -210,12 +206,10 @@ def evaluate_pipeline( Orchestrates the full evaluation process: Load -> Inference -> Denormalize -> Calculate -> Save. """ - # 1. Validation if not checkpoint_path.exists() or not stats_path.exists(): print(f"Error: Missing checkpoint ({checkpoint_path}) or stats file ({stats_path}).") return - # 2. Setup Resources device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Load Statistics @@ -233,10 +227,8 @@ def evaluate_pipeline( model.load_state_dict(ckpt["model_state_dict"]) print(f"Model loaded: {checkpoint_path.name}") - # 3. Inference (Normalized) preds_norm, targets_norm = run_inference(model, test_loader, device) - # 4. Denormalization # Formula: X_real = X_norm * Std + Mean preds_real = preds_norm * param_std + param_mean targets_real = targets_norm * param_std + param_mean @@ -245,7 +237,6 @@ def evaluate_pipeline( preds_np = preds_real.numpy() targets_np = targets_real.numpy() - # 5. Metrics & Data Construction metrics = calculate_metrics(preds_np, targets_np) # Define parameter names (ensure this matches model output dimension) @@ -254,7 +245,6 @@ def evaluate_pipeline( # Create Master DataFrame results_df = generate_results_df(preds_np, targets_np, metrics["errors"], param_names) - # 6. Output & Reporting print_metrics_table(metrics, param_names) if report_csv_path: diff --git a/runs/exp06_1lay_6out/fitting_engine.py b/runs/exp06_1lay_6out/fitting_engine.py index 7b12d54..63e311f 100644 --- a/runs/exp06_1lay_6out/fitting_engine.py +++ b/runs/exp06_1lay_6out/fitting_engine.py @@ -25,7 +25,6 @@ def __init__(self, q: np.ndarray, R: np.ndarray, film_params: ParamSet, sio2_par self.q = q self.R = R / R.max() - # 1. 입력값 정리 (Human Readable Scale로 통일) self.init_d = float(film_params.thickness) self.init_sigma = float(film_params.roughness) self.init_sld = float(film_params.sld) @@ -35,7 +34,6 @@ def __init__(self, q: np.ndarray, R: np.ndarray, film_params: ParamSet, sio2_par self.model = self._build_model() def _build_model(self): - # --- Data Load --- ds = DataSet(name="XRR_Data") ds.x_raw = self.q ds.y_raw = self.R @@ -45,7 +43,6 @@ def _build_model(self): model = Model() model.data = DataList([ds]) - # --- Script Generation --- script = rf""" import numpy as np from genx.models.spec_nx import Sample, Stack, Layer, Instrument, Specular @@ -137,8 +134,7 @@ def run(self, verbose=True): pars = Parameters() model = self.model - # --- Parameters Registration --- - # 1. Film (NN Prediction based) + # film thickness is bounded around the NN prediction p_f_d = pars.append("v.set_f_d", model) p_f_d.min = max(1.0, self.init_d * 0.5) p_f_d.max = self.init_d * 1.5 @@ -151,7 +147,6 @@ def run(self, verbose=True): p_f_sld.min = 0.1 p_f_sld.max = 50.0 - # 2. SiO2 p_s_d = pars.append("v.set_s_d", model) p_s_d.value = 15.0 p_s_d.min = 5.0 @@ -167,7 +162,6 @@ def run(self, verbose=True): p_s_sld.min = 10.0 p_s_sld.max = 23.0 - # 3. Instrument p_i0 = pars.append("v.set_i0", model) p_i0.value = 1.0 p_i0.min = 0.1 @@ -175,7 +169,6 @@ def run(self, verbose=True): model.parameters = pars - # --- Step 1: I0 Fitting --- if verbose: print("\n[GenX] Step 1: Fitting I0 (Linear)...") model.set_fom_func(fom_funcs.diff) @@ -194,7 +187,6 @@ def run(self, verbose=True): if verbose: print(f" -> I0 Fitted: {p_i0.value:.4f}") - # --- Step 2: Full Fitting --- if verbose: print("[GenX] Step 2: Fitting All Params (Log)...") model.set_fom_func(fom_funcs.log) diff --git a/runs/exp06_1lay_6out/inference.py b/runs/exp06_1lay_6out/inference.py index 85538b7..7010516 100644 --- a/runs/exp06_1lay_6out/inference.py +++ b/runs/exp06_1lay_6out/inference.py @@ -11,7 +11,6 @@ def __init__(self, exp_dir=None): """ Initializes the inference engine: loads configuration, model, and statistics. """ - # 1. Set Path if exp_dir is None: exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] else: @@ -23,21 +22,17 @@ def __init__(self, exp_dir=None): self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[Inference] Device: {self.device}") - # 2. Check files if not self.stats_file.exists(): raise FileNotFoundError(f"Statistics file not found: {self.stats_file}") if not self.checkpoint_file.exists(): raise FileNotFoundError(f"Checkpoint file not found: {self.checkpoint_file}") - # 3. Master Grid Settings self.q_min = CONFIG["simulation"]["q_min"] self.q_max = CONFIG["simulation"]["q_max"] self.n_points = CONFIG["simulation"]["q_points"] - # 4. Initialize Processor (Handles Preprocessing & Denormalization) self.processor = XRRPreprocessor(q_min=self.q_min, q_max=self.q_max, n_points=self.n_points, stats_file=self.stats_file, device=self.device) - # 5. Load Model self._load_model() def _load_model(self): @@ -74,16 +69,13 @@ def predict(self, q_raw, R_raw): Returns: np.array: Predicted physical parameters [thickness, roughness, sld]. """ - # 1. Preprocess (Delegate to Processor) # Output Shape: (2, n_points) -> Add Batch Dimension: (1, 2, n_points) x = self.processor.process_input(q_raw, R_raw).unsqueeze(0).to(self.device) - # 2. Inference with torch.no_grad(): # Output: (1, 3) -> Normalized Params y_pred_norm = self.model(x).squeeze(0) - # 3. Denormalize (Delegate to Processor) y_pred = self.processor.denormalize_params(y_pred_norm) return y_pred diff --git a/runs/exp06_1lay_6out/inference_pipeline.py b/runs/exp06_1lay_6out/inference_pipeline.py index d47804a..0c4815d 100644 --- a/runs/exp06_1lay_6out/inference_pipeline.py +++ b/runs/exp06_1lay_6out/inference_pipeline.py @@ -12,25 +12,25 @@ def run_xrr_analysis( data_path: str | Path, weights_dir: str | Path, inference_engine: XRRInferenceEngine | None = None, verbose: bool = True, show_plot: bool = True ) -> dict: """ - 단일 XRR 데이터 파일에 대해 [로드 -> NN 추론 -> GenX 피팅] 파이프라인을 실행합니다. + Run the [load -> NN inference -> GenX fit] pipeline on one XRR data file. Args: - data_path (str | Path): 분석할 .dat 파일 경로 - weights_dir (str | Path): 학습된 NN 모델 폴더 경로 + data_path (str | Path): path of the .dat file to analyze + weights_dir (str | Path): folder of the trained NN model inference_engine (XRRInferenceEngine, optional): - 이미 로드된 추론 엔진 객체. None이면 함수 내부에서 새로 로드합니다. - (반복 호출 시 엔진을 미리 로드해서 넘겨주면 속도가 훨씬 빠릅니다) - verbose (bool): 진행 상황 출력 여부 - show_plot (bool): 결과 그래프 표시 여부 + Already loaded engine. Loaded inside the function when None. + (Passing a preloaded engine is much faster for repeated calls.) + verbose (bool): print progress + show_plot (bool): show the result plot Returns: - dict: 분석 결과 { - "nn_preds": (d, sig, sld), # NN 예측값 - "final_params": dict, # GenX 최종 피팅 파라미터 - "fitter": GenXFitter 객체, # 피팅 객체 (시뮬레이션 데이터 포함) - "q": np.array, # q 데이터 - "R_measured": np.array, # 측정된 R - "R_fit": np.array # 피팅된 R + dict: analysis result { + "nn_preds": (d, sig, sld), # NN prediction + "final_params": dict, # final GenX fit parameters + "fitter": GenXFitter, # fitter object (holds the simulated data) + "q": np.array, # q data + "R_measured": np.array, # measured R + "R_fit": np.array # fitted R } """ data_path = Path(data_path) @@ -41,25 +41,16 @@ def run_xrr_analysis( print(f"🚀 XRR Analysis Pipeline: {data_path.name}") print("=" * 60) - # --------------------------------------------------------- - # 1. 데이터 로드 (Data Loading) - # --------------------------------------------------------- if not data_path.exists(): raise FileNotFoundError(f"[Error] 데이터 파일을 찾을 수 없습니다: {data_path}") - # Pandas Series 문제 방지를 위해 np.array로 명시적 변환 tths, R_raw = load_xrr_dat(data_path) - # tth -> q 변환 q_raw = tth2q(tths) if verbose: print(f"[Data] Loaded {len(q_raw)} points.") - # --------------------------------------------------------- - # 2. NN 초기값 예측 (Neural Network Inference) - # --------------------------------------------------------- - # 엔진이 주입되지 않았으면 새로 로드 (단발성 실행용) if inference_engine is None: if verbose: print("[Init] Loading Inference Engine...") @@ -76,24 +67,16 @@ def run_xrr_analysis( print(f" [Film] Thickness: {pred_f_d:.2f}, Rough: {pred_f_sig:.2f}, SLD: {pred_f_sld:.3f}") print(f" [SiO2] Thickness: {pred_s_d:.2f}, Rough: {pred_s_sig:.2f}, SLD: {pred_s_sld:.3f}") - # GenXFitter용 파라미터 객체 생성 film_params = ParamSet(pred_f_d, pred_f_sig, pred_f_sld) sio2_params = ParamSet(pred_s_d, pred_s_sig, pred_s_sld) - # --------------------------------------------------------- - # 3. GenX 정밀 피팅 (GenX Refinement) - # --------------------------------------------------------- if verbose: print("\n[Step 2] GenX Fitting (Optimization)...") fitter = GenXFitter(q_raw, R_raw, film_params, sio2_params) - # 피팅 실행 final_results = fitter.run(verbose=verbose) - # --------------------------------------------------------- - # 4. 결과 정리 및 시각화 - # --------------------------------------------------------- if verbose: print("\n" + "-" * 40) print("FINAL ANALYSIS RESULT") @@ -105,23 +88,16 @@ def run_xrr_analysis( if show_plot: fitter.plot() - # 결과 반환 return {"nn_preds": preds, "final_params": final_results, "fitter": fitter, "q": q_raw, "R_measured": R_raw, "R_fit": fitter.model.data[0].y_sim} -# ========================================================= -# 사용 예시 (Main) -# ========================================================= def main(): - # 설정 target_file = Path(r"C:\Users\IsaacYong\Documents\카카오톡 받은 파일\#1.dat") weights_path = Path(r"D:\data\XRR_AI\one_layer\test") try: - # 함수 호출 result = run_xrr_analysis(data_path=target_file, weights_dir=weights_path, verbose=True, show_plot=True) - # 결과 데이터 활용 예시 print(f"최종 피팅된 두께: {result['final_params']['f_d']:.2f} Å") except Exception as e: diff --git a/runs/exp06_1lay_6out/main.py b/runs/exp06_1lay_6out/main.py index 64523ee..1c1bb86 100644 --- a/runs/exp06_1lay_6out/main.py +++ b/runs/exp06_1lay_6out/main.py @@ -81,7 +81,6 @@ def main(): print("=== 1-Layer XRR Regression Pipeline Started ===") if not Path(CONFIG["base_dir"]).exists(): raise FileNotFoundError(f"Base directory {CONFIG['base_dir']} does not exist.") - # 1. Setup and Path Preparation set_seed(42) exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] @@ -99,13 +98,10 @@ def main(): ) save_config(CONFIG, config_file_json) print(f"Config file saved at '{config_file_json}'") - # 2. Data Preparation ensure_data_exists(qs, CONFIG, h5_file) - # 3. Create Loaders train_loader, val_loader, test_loader = get_dataloaders(qs, CONFIG, h5_file, stats_file) - # 4. Model Initialization print("Initializing model...") model = XRR1DRegressor( q_len=CONFIG["simulation"]["q_points"], @@ -120,7 +116,6 @@ def main(): param_count = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"Model parameters: {param_count:,}") - # 5. Training Setup and Execution trainer = Trainer( model=model, train_loader=train_loader, @@ -134,7 +129,6 @@ def main(): print("Starting training...") trainer.train(CONFIG["training"]["epochs"]) - # 6. Final Evaluation print("\n" + "=" * 50) print("Performing Final Test Evaluation") print("=" * 50) diff --git a/runs/exp06_1lay_6out/simulate.py b/runs/exp06_1lay_6out/simulate.py index a4432e9..42c7f9a 100644 --- a/runs/exp06_1lay_6out/simulate.py +++ b/runs/exp06_1lay_6out/simulate.py @@ -7,18 +7,16 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): """ - 1-layer XRR 데이터 생성 + Generate 1-layer XRR data. Args: - config: main.py의 CONFIG 딕셔너리 (simulation, paths, param_ranges 포함) + config: CONFIG dict of main.py (simulation, paths, param_ranges) """ print("=== 1-Layer XRR 데이터 생성 시작 ===") - # config에서 모든 파라미터 추출 simulation = config["simulation"] param_ranges = config["param_ranges"] h5_file = Path(h5_file) - # 출력 디렉토리 생성 output_dir = h5_file.parent output_dir.mkdir(exist_ok=True, parents=True) diff --git a/runs/exp06_1lay_6out/train.py b/runs/exp06_1lay_6out/train.py index 43b799b..9629975 100644 --- a/runs/exp06_1lay_6out/train.py +++ b/runs/exp06_1lay_6out/train.py @@ -42,7 +42,7 @@ def __init__( self.criterion = nn.MSELoss() # Scheduler: Reduce Learning Rate when validation loss plateaus - # Note: 'verbose' argument is deprecated in newer PyTorch versions, removed here. + # the scheduler's 'verbose' argument is deprecated in newer PyTorch versions, so it is omitted self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=5, factor=0.5) # Training State diff --git a/runs/exp06_1lay_6out/tune.py b/runs/exp06_1lay_6out/tune.py index 8394407..ac243f4 100644 --- a/runs/exp06_1lay_6out/tune.py +++ b/runs/exp06_1lay_6out/tune.py @@ -13,9 +13,6 @@ from tqdm import tqdm from xrr_model import XRR1DRegressor -# --------------------------------------------------------- -# 1. Settings -# --------------------------------------------------------- N_TRIALS = 50 # Total number of trials N_EPOCHS_PER_TRIAL = 15 # Epochs per trial (keep it lower than full training) STUDY_NAME = "xrr_optimization" @@ -47,7 +44,7 @@ def get_dataloaders(batch_size): train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") - # Important: pin_memory=False for Windows stability if needed + # pin_memory=False if needed for Windows stability train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True) val_loader = DataLoader(val_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True) @@ -55,9 +52,6 @@ def get_dataloaders(batch_size): def objective(trial): - # --------------------------------------------------------- - # 2. Define Hyperparameter Search Space - # --------------------------------------------------------- # Architecture n_channels = trial.suggest_categorical("n_channels", [32, 64]) depth = trial.suggest_int("depth", 3, 5) @@ -69,9 +63,6 @@ def objective(trial): weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-3, log=True) batch_size = trial.suggest_categorical("batch_size", [32, 64, 128]) # Keep max low to avoid OOM - # --------------------------------------------------------- - # 3. Setup (Model, Data, Optimizer) - # --------------------------------------------------------- device = torch.device("cuda" if torch.cuda.is_available() else "cpu") try: @@ -94,12 +85,8 @@ def objective(trial): criterion = nn.MSELoss() scaler = torch.amp.GradScaler("cuda") - # --------------------------------------------------------- - # 4. Training Loop (Simplified for Tuning) - # --------------------------------------------------------- pbar = tqdm(range(N_EPOCHS_PER_TRIAL)) for epoch in pbar: - # --- Train --- model.train() for inputs, targets in train_loader: inputs, targets = inputs.to(device), targets.to(device) @@ -116,7 +103,6 @@ def objective(trial): scaler.step(optimizer) scaler.update() - # --- Validate --- model.eval() val_loss = 0.0 with torch.no_grad(): @@ -128,7 +114,6 @@ def objective(trial): avg_val_loss = val_loss / len(val_loader) - # --- Pruning (Early Stopping) --- # Report current result to Optuna trial.report(avg_val_loss, epoch) diff --git a/runs/exp06_1lay_6out/xrr_model.py b/runs/exp06_1lay_6out/xrr_model.py index f4db7e5..6f558c3 100644 --- a/runs/exp06_1lay_6out/xrr_model.py +++ b/runs/exp06_1lay_6out/xrr_model.py @@ -36,9 +36,7 @@ def __init__( "dropout": dropout, } - # --------------------------------------------------------- # 1D CNN Encoder - # --------------------------------------------------------- layers = [] in_ch = input_channels @@ -61,9 +59,7 @@ def __init__( # Summarizes features across the entire q-range self.global_pool = nn.AdaptiveAvgPool1d(1) - # --------------------------------------------------------- # MLP Regressor - # --------------------------------------------------------- encoder_out_dim = in_ch # The channel count of the last conv layer self.regressor = nn.Sequential( nn.Linear(encoder_out_dim, mlp_hidden), @@ -71,7 +67,7 @@ def __init__( nn.Dropout(dropout), nn.Linear(mlp_hidden, mlp_hidden // 2), nn.ReLU(inplace=True), - nn.Linear(mlp_hidden // 2, output_dim), # 출력: Thickness, Roughness, SLD + nn.Linear(mlp_hidden // 2, output_dim), # thickness, roughness, SLD ) self._init_weights() @@ -97,11 +93,8 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: Returns: Predicted normalized parameters of shape (Batch, output_dim). """ - # 1. Extract Features features = self.encoder(x) # Output: (B, Last_Ch, Reduced_Len) - # 2. Global Pooling pooled = self.global_pool(features).squeeze(-1) # Output: (B, Last_Ch) - # 3. Regression return self.regressor(pooled) diff --git a/runs/exp07_fourier_hires/ablation.py b/runs/exp07_fourier_hires/ablation.py index fc3fe29..2aca875 100644 --- a/runs/exp07_fourier_hires/ablation.py +++ b/runs/exp07_fourier_hires/ablation.py @@ -1,19 +1,19 @@ """ -exp07 요소별 기여도 분해(Ablation) 러너. +Ablation runner that separates the contribution of each exp07 improvement. -exp02와 exp07은 시뮬레이터/q-grid/층구조/라벨 범위가 모두 달라 수치 직접 비교가 -성립하지 않는다. 따라서 동일 데이터셋(exp07 h5) 위에서 개선 요소를 하나씩 끄고 -학습하여 각 요소의 기여도를 분리 측정한다. +exp02 and exp07 differ in simulator, q-grid, layer structure and label ranges, so their +numbers cannot be compared directly. Instead the improvements are switched off one at a +time on the same dataset (the exp07 h5) and each variant is trained separately. -측정 요소: +Factors measured: - Fourier feature (model.use_fourier) - Physics augmentation (training.augment) - - q 해상도 (simulation.q_points, 원본 h5에서 재샘플링) + - q resolution (simulation.q_points, resampled from the source h5) -주의: q_points를 줄여도 데이터 재생성은 불필요하다. XRRPreprocessor가 원본 q 그리드에서 -target_q로 보간하므로, 2000점 h5 하나로 저해상도 조건을 만들 수 있다. +Regenerating data is not needed when q_points is reduced: XRRPreprocessor interpolates from +the source q grid to target_q, so one 2000-point h5 yields the low-resolution conditions. -사용: +Usage: python runs/exp07_fourier_hires/ablation.py --dry-run python runs/exp07_fourier_hires/ablation.py python runs/exp07_fourier_hires/ablation.py --only A_full C_no_augment --epochs 5 @@ -37,9 +37,7 @@ from train import Trainer from xrr_model import XRRPhysicsModel -# ----------------------------------------------------------------------------- -# Variant 정의: 기준(CONFIG)에 덮어쓸 부분만 기술 -# ----------------------------------------------------------------------------- +# variants list only what overrides the base CONFIG VARIANTS: dict[str, dict] = { "A_full": {}, "B_no_fourier": { @@ -63,7 +61,7 @@ def deep_merge(base: dict, override: dict) -> dict: - """base의 깊은 복사본에 override를 재귀 병합""" + """Recursively merge override into a deep copy of base.""" out = copy.deepcopy(base) for key, value in override.items(): if isinstance(value, dict) and isinstance(out.get(key), dict): @@ -112,7 +110,7 @@ def make_loaders( modes = ["train", "val", "test"] if augment_eval is not None: common["augment_eval"] = augment_eval - # augment_eval을 켜려면 augmentation 자체가 활성이어야 한다 + # augment_eval needs augmentation itself to be active common["augment"] = common["augment"] or augment_eval loaders = [] @@ -153,11 +151,11 @@ def steps_per_epoch(loader: DataLoader) -> int: def resolve_epochs(cfg: dict, train_loader: DataLoader, budget_steps: int | None, epochs_override: int | None) -> int: """ - Variant마다 epoch당 step 수가 다르므로(augmentation expand_factor 차이), - epoch이 아닌 optimizer step 수를 맞춰야 공정 비교가 된다. + Variants differ in steps per epoch (augmentation expand_factor), so a fair comparison + matches the number of optimizer steps, not epochs. - epochs_override는 스모크 테스트 전용 - 공정 비교를 깨뜨린다. - 학습 예산을 줄이려면 --baseline-epochs를 쓸 것. + epochs_override is for smoke tests only and breaks the fair comparison. + To cut the training budget use --baseline-epochs. """ if epochs_override is not None: return epochs_override @@ -170,9 +168,9 @@ def resolve_epochs(cfg: dict, train_loader: DataLoader, budget_steps: int | None def resolve_patience(cfg: dict, train_loader: DataLoader, budget_steps: int | None, baseline_spe: int | None) -> int: """ - early stopping patience도 epoch 단위이므로 step 예산을 맞추면 함께 환산해야 한다. - 그렇지 않으면 epoch당 step이 적은 variant(augmentation 없음)가 훨씬 이른 - 시점에 중단되어 비교가 오염된다. + Early-stopping patience is also in epochs, so it must be converted along with the step + budget. Otherwise a variant with fewer steps per epoch (no augmentation) stops much earlier + and the comparison is contaminated. """ patience = cfg["training"]["patience"] if budget_steps is None or baseline_spe is None: @@ -186,15 +184,15 @@ def eval_variant( name: str, cfg: dict, var_dir: Path, h5_file: Path, stats_file: Path, qs: np.ndarray, checkpoint_file: Path, augment_eval: bool, tag: str ) -> dict: """ - 학습 없이 저장된 best.pt로 평가만 수행. + Evaluate with the saved best.pt, without training. - augment_eval=True면 측정 노이즈가 실린 test set에서 평가한다. clean simulation - 평가는 augmentation 없이 학습한 variant에게 유리하게 편향되므로(학습/평가 분포 일치), - 두 도메인 모두에서 재는 것이 필요하다. + With augment_eval=True the test set carries measurement noise. Clean-simulation evaluation + is biased in favor of variants trained without augmentation (train and eval distributions + match), so both domains need to be measured. - 노이즈는 __getitem__마다 무작위로 생성되므로, variant 간 비교가 성립하려면 - 동일한 노이즈 시퀀스를 써야 한다. numpy 전역 상태는 DataLoader worker 프로세스로 - 결정론적으로 전달되지 않으므로 num_workers=0으로 고정하고 시드를 다시 심는다. + Noise is drawn randomly in each __getitem__, so variants need the same noise sequence to be + comparable. numpy global state is not passed deterministically to DataLoader workers, so + num_workers=0 is fixed and the seed is set again. """ if not checkpoint_file.exists(): print(f"⚠️ Skipping {name}: checkpoint not found at {checkpoint_file}") @@ -310,7 +308,7 @@ def run_variant( trainer.train(epochs, resume_from=resume_path if resume_path.exists() else None) train_seconds = time.perf_counter() - t0 - # 재개 학습이면 이번 호출분만 잰다. 누적 시간은 이전 실행분을 더해 보관한다. + # on a resumed run only this call is timed; the time of earlier runs is added and stored time_log = var_dir / "train_time.json" prev_seconds = 0.0 if time_log.exists(): @@ -377,8 +375,8 @@ def run_variant( def merge_summary(rows: list[dict], out_csv: Path) -> pd.DataFrame: """ - --only로 일부 variant만 돌려도 기존 summary의 나머지 행을 잃지 않도록 병합한다. - 이번에 돌린 variant는 새 결과로 대체하고, 나머지는 그대로 둔다. + Merge so that running only some variants with --only does not lose the other rows of the + existing summary. Variants run now replace their rows; the rest are kept. """ df_new = pd.DataFrame(rows) if out_csv.exists(): @@ -461,7 +459,7 @@ def main(): if args.expand_factor is not None: for cfg in cfgs.values(): - # augmentation을 쓰지 않는 variant는 expand가 무의미하므로 1로 고정된 채 둔다 + # variants without augmentation keep expand_factor at 1, since expanding is meaningless for them if cfg["training"]["augment"]: cfg["training"]["expand_factor"] = args.expand_factor print(f"[Override] expand_factor = {args.expand_factor} (augmented variants only)") @@ -476,17 +474,17 @@ def main(): print("\n[Dry run] 학습을 수행하지 않고 종료합니다.") return - # 원본 데이터는 기준 config(2000점)로 1회만 생성해 모든 variant가 공유 + # source data is generated once with the base config (2000 points) and shared by all variants base_qs = build_qs(CONFIG["simulation"]) if not h5_file.exists(): print(f"📦 Source dataset missing. Generating at {h5_file} ...") simulate.generate_1layer_data(base_qs, CONFIG, h5_file) - # step 예산 산정: 기준 variant의 (baseline epochs × steps/epoch) + # step budget: baseline epochs x steps per epoch of the baseline variant budget_steps = None baseline_spe = None if args.budget_mode == "steps" and args.epochs is None and not args.eval_only: - # expand_factor 덮어쓰기가 적용된 config를 그대로 사용해야 예산이 어긋나지 않음 + # use the config with the expand_factor override applied, or the budget will not match base_cfg = cfgs.get(args.baseline) if base_cfg is None: base_cfg = deep_merge(CONFIG, VARIANTS[args.baseline]) @@ -539,7 +537,7 @@ def main(): ) rows.append(row) - # 매 variant마다 중간 저장 (중단 대비) + # save after every variant, in case of interruption df = merge_summary(rows, out_csv) print(f"[Save] Ablation summary → {out_csv}") @@ -549,7 +547,7 @@ def main(): cols = ["variant", "eval_domain", "q_points", "use_fourier", "augment", "params", "Thickness_MAE", "Roughness_MAE", "SLD_MAE", "Thickness_R2"] print(df[[c for c in cols if c in df.columns]].to_string(index=False)) print("=" * 100) - # json은 merge_summary가 csv와 함께 이미 기록했다 + # the json is already written by merge_summary together with the csv if __name__ == "__main__": diff --git a/runs/exp07_fourier_hires/analyze.py b/runs/exp07_fourier_hires/analyze.py index 30a7b1e..ae6a3dd 100644 --- a/runs/exp07_fourier_hires/analyze.py +++ b/runs/exp07_fourier_hires/analyze.py @@ -3,17 +3,14 @@ import matplotlib.pyplot as plt import numpy as np from engine.fitting import GenXFitter, XRRConfig - -# 엔진 모듈 from engine.inference import XRRInferenceEngine -# 프로젝트 코어 유틸리티 from reflecto_exp.math_utils import i0_normalize from reflecto_exp.simulate.simul_genx import ParamSet, param2refl class XRRAnalyzer: - """XRR 분석 및 테스트 통합 클래스 (AI + GenX)""" + """Analysis class for XRR: AI inference followed by GenX refinement.""" def __init__(self, exp_dir: Path, device: str = "cpu"): self.exp_dir = Path(exp_dir) @@ -21,16 +18,14 @@ def __init__(self, exp_dir: Path, device: str = "cpu"): print(f"🚀 XRRAnalyzer Initialized with model at: {self.exp_dir.name}") def generate_test_data(self, thick=350.0, rough=5.0, sld=15.0) -> tuple[np.ndarray, np.ndarray, ParamSet]: - """[Test용] 직접 파라미터를 입력하여 시뮬레이션 데이터를 생성합니다.""" print(f"🧪 Generating synthetic test data: d={thick}, sig={rough}, sld={sld}") - # 모델이 학습된 표준 Grid 사용 + # the standard grid the model was trained on qs = self.engine.target_qs true_params = ParamSet(thickness=thick, roughness=rough, sld=sld) - # 시뮬레이션 (약간의 노이즈 추가 가능) R_clean = param2refl(qs, [true_params]) - noise = np.random.normal(0, 0.02, size=len(R_clean)) # 가우시안 노이즈 시뮬레이션 + noise = np.random.normal(0, 0.02, size=len(R_clean)) R_noisy = np.abs(R_clean * (1 + noise)) return qs, R_noisy, true_params @@ -45,19 +40,17 @@ def analyze( true_params: ParamSet | None = None, ): """ - 통합 분석 파이프라인 - - q, R: 입력 데이터 - - save_results: 파일 저장 여부 (옵션) - - true_params: 테스트 데이터인 경우 Ground Truth 비교용 + Full analysis pipeline. + - q, R: input data + - save_results: whether to save files (optional) + - true_params: ground truth to compare against when the data is a test dataset """ R = i0_normalize(R) - # 1. AI Inference print("Step 1: AI Inference...") ai_guess = self.engine.predict(q, R) print(f" [AI Guess] {ai_guess}") - # 2. Plot AI Guess (요청하신 AI 단독 플롯) if save_results: save_path = self.exp_dir / f"guess_{filename}.png" self.engine.plot_ai_guess(q, R, ai_guess, save_path=save_path) @@ -66,7 +59,6 @@ def analyze( final_params = ai_guess R_fit = param2refl(q, [ai_guess]) - # 3. GenX Refinement if run_fitting: print("Step 2: GenX Refinement (Fitting)...") fit_config = XRRConfig(thickness_search_ratio=(0.8, 1.2), steps_structure=400, steps_fine=600) @@ -77,24 +69,19 @@ def analyze( R_fit = res["R_sim"] print(f" [Final Fit] {final_params}") - # 4. Final Comparison Visualization self._visualize_final(q, R, ai_guess, final_params, R_fit, filename, save_results, true_params) return final_params def _visualize_final(self, q, R, ai_p, final_p, R_fit, filename, save_results, true_p): - """결과 통합 시각화 및 리포트""" plt.figure(figsize=(10, 7)) plt.plot(q, R, "ko", markersize=3, alpha=0.2, label="Measured Data") - # AI Guess (파란 점선) R_ai = param2refl(q, [ai_p]) plt.plot(q, R_ai, "b--", alpha=0.7, label=f"AI Guess (d={ai_p.thickness:.1f}Å)") - # Final Fit (빨간 실선) plt.plot(q, R_fit, "r-", lw=2, label=f"Final Fit (d={final_p.thickness:.1f}Å)") - # 만약 Ground Truth(테스트 데이터)가 있다면 표시 if true_p: plt.axvline(x=0, color="green", label=f"GT: d={true_p.thickness:.1f}Å", alpha=0) print(f"🎯 Accuracy (Thick): {100 - abs(true_p.thickness - final_p.thickness) / true_p.thickness * 100:.2f}%") @@ -114,27 +101,21 @@ def _visualize_final(self, q, R, ai_p, final_p, R_fit, filename, save_results, t plt.show() -# ----------------------------------------------------------------------------- -# Execution Block -# ----------------------------------------------------------------------------- if __name__ == "__main__": - # 환경 설정 EXP_PATH = Path(r"C:\Warehouse\data\XRR_AI\mask\exp07_fourier_hires") analyzer = XRRAnalyzer(EXP_PATH) - # --- [MODE 1] 직접 데이터 생성해서 테스트 --- print("\n>>> Running Mode 1: Synthetic Data Test") q_syn, R_syn, gt_params = analyzer.generate_test_data(thick=425.5, rough=3.2, sld=22.1) analyzer.analyze( q_syn, R_syn, run_fitting=True, - save_results=False, # 테스트 시 저장 안 함 + save_results=False, filename="synthetic_test", true_params=gt_params, ) - # --- [MODE 2] 실측 파일 로드해서 분석 --- # print("\n>>> Running Mode 2: Real Data Analysis") # DATA_FILE = Path(r"C:\Warehouse\data\dat_files\jinhuan\#1_xrr.dat") # if DATA_FILE.exists(): @@ -142,5 +123,5 @@ def _visualize_final(self, q, R, ai_p, final_p, R_fit, filename, save_results, t # q_real = tth2q(tth) # analyzer.analyze(q_real, R_raw, # run_fitting=True, - # save_results=True, # 결과 파일 저장 + # save_results=True, # filename=DATA_FILE.stem) diff --git a/runs/exp07_fourier_hires/augmentations.py b/runs/exp07_fourier_hires/augmentations.py index 6a0a831..d2d5d1c 100644 --- a/runs/exp07_fourier_hires/augmentations.py +++ b/runs/exp07_fourier_hires/augmentations.py @@ -32,70 +32,59 @@ def __init__( def _gauss_footprint(self, theta_deg: np.ndarray, beam_w: float, sample_len: float) -> np.ndarray: """ - GenX의 GaussIntensity 로직을 NumPy용으로 구현 - theta_deg: 입사각 (도) - beam_w: 빔의 표준편차 (mm) - sample_len: 시료 길이 (mm) + GenX GaussIntensity logic implemented for NumPy. + theta_deg: incidence angle (deg) + beam_w: standard deviation of the beam (mm) + sample_len: sample length (mm) """ - # GenX 로직: GaussIntensity(alpha, s1, s2, sigma_x) - # s1 = s2 = sample_len / 2.0 (시료 중심 기준 대칭 가정) + # GenX logic: GaussIntensity(alpha, s1, s2, sigma_x) with s1 = s2 = sample_len / 2 (symmetric about the sample center) rad = np.pi / 180.0 sqrt2 = np.sqrt(2.0) - # 빔과 시료가 이루는 투영 각도 계산 sinalpha = np.sin(theta_deg * rad) - # 0으로 나누기 방지 (theta=0일 때) + # avoid division by zero at theta = 0 sinalpha = np.maximum(sinalpha, 1e-9) s_half = sample_len / 2.0 - # Gaussian Beam Spill-over 계산 (GenX 수식) - # 빔이 시료 영역 [-s_half, +s_half] 안에 들어오는 비율 적분 + # Gaussian beam spill-over (GenX formula): fraction of the beam inside [-s_half, +s_half] common = sinalpha / (sqrt2 * beam_w) - # erf는 기함수이므로 erf(s * common) + erf(s * common) = 2 * erf(...) - # 결과 = (2 * erf(...)) / 2.0 = erf(...) + # erf is odd, so the two erf terms are equal and (2 * erf(...)) / 2 = erf(...) correction = erf(s_half * common) return correction def apply_footprint(self, q: np.ndarray, R: np.ndarray) -> np.ndarray: - """랜덤한 빔 폭과 시료 크기를 적용하여 저각 강도 감쇄 시뮬레이션""" + """Apply a random beam width and sample size to simulate the low-angle intensity loss.""" beam_w = np.random.uniform(*self.beam_width_range) sample_len = np.random.uniform(*self.sample_len_range) - # q -> theta 변환 (X-ray 파장 1.54A 기준) + # q -> theta (X-ray wavelength 1.54 A) # q = 4pi/lambda * sin(theta) => theta = arcsin(q * lambda / 4pi) wavelength = 1.54 sin_theta = q * wavelength / (4 * np.pi) - # arcsin 범위 안전장치 (-1 ~ 1) + # keep the arcsin argument in range sin_theta = np.clip(sin_theta, 0, 1) theta_deg = np.degrees(np.arcsin(sin_theta)) - # GenX 스타일 Gaussian Footprint 보정 계수 계산 foocor = self._gauss_footprint(theta_deg, beam_w, sample_len) return R * foocor def __call__(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - # 확률적으로 Augmentation 적용 여부 결정 if np.random.rand() > self.prob: return q, R R_aug = R.copy() - # ----------------------------------------------------------- - # [Step 1] Geometric Footprint (물리적 현상: 가장 먼저 발생) - # ----------------------------------------------------------- + # order follows the physics: footprint, instrument resolution, detector noise and scale, then q misalignment if self.do_footprint: R_aug = self.apply_footprint(q, R_aug) - # ----------------------------------------------------------- - # [Step 2] Instrument Resolution (기기적 현상: 광학계 통과) - # ----------------------------------------------------------- - # 풋프린트로 깎인 곡선이 해상도에 의해 뭉개져야 자연스러움 + # resolution smears the footprint-cut curve (physical order) res_sigma_q = np.random.uniform(*self.res_sigma_range) sigma_px = res_sigma_q / self.delta_q @@ -103,10 +92,6 @@ def __call__(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.ndarray if sigma_px > 0: R_aug = gaussian_filter1d(R_aug, sigma=sigma_px, mode="nearest") - # ----------------------------------------------------------- - # [Step 3] Detectors & Electronics (노이즈 및 스케일) - # ----------------------------------------------------------- - # Global Scaling (I0 fluctuation) scale = np.random.uniform(1 - self.intensity_noise_scale, 1 + self.intensity_noise_scale) R_aug *= scale @@ -118,16 +103,14 @@ def __call__(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.ndarray R_aug = R_aug + np.abs(bg) # Poisson Noise (Counting statistics - Shot noise) - # 신호가 강한 곳은 노이즈도 큼 (Signal dependent) + # signal-dependent noise: a stronger signal has more noise s_rand = pow(10, np.random.uniform(5, 8)) R_aug = apply_poisson_noise(R_aug, s=s_rand) - # ----------------------------------------------------------- - # [Step 4] Q-axis Misalignment (Calibration error) - # ----------------------------------------------------------- + # q-axis misalignment (calibration error) if self.q_shift_sigma > 0: q_shift = np.random.normal(0, self.q_shift_sigma) - # q축이 밀리면 데이터는 반대 방향으로 이동 효과 + # a shifted q axis moves the data the opposite way R_aug = np.interp(q, q + q_shift, R_aug, left=R_aug[0], right=R_aug[-1]) return q, R_aug diff --git a/runs/exp07_fourier_hires/config.py b/runs/exp07_fourier_hires/config.py index 3934cb7..b5729cb 100644 --- a/runs/exp07_fourier_hires/config.py +++ b/runs/exp07_fourier_hires/config.py @@ -46,13 +46,13 @@ "intensity_scale": 0.25, "q_shift_sigma": 0.004, "res_sigma_range": (0.0001, 0.006), - # 평가셋에도 augmentation을 적용할지 여부. - # False = clean simulation으로 평가 (실측 대비 낙관적) - # True = 측정 노이즈가 실린 조건에서 평가 + # whether augmentation is also applied to the eval sets + # False: evaluate on clean simulation (optimistic compared with measured data) + # True: evaluate with measurement noise "augment_eval": False, }, "debug": { - # 전 레이어 NaN/Inf 검사 훅. 학습 속도를 크게 떨어뜨리므로 기본 비활성. + # hook that checks every layer for NaN/Inf; off by default because it slows training a lot "hooks": False, }, } diff --git a/runs/exp07_fourier_hires/dataset.py b/runs/exp07_fourier_hires/dataset.py index d27f8aa..77d9d21 100644 --- a/runs/exp07_fourier_hires/dataset.py +++ b/runs/exp07_fourier_hires/dataset.py @@ -19,7 +19,6 @@ def __init__(self, qs: np.ndarray, stats_file=None, device="cpu"): self.load_stats(stats_file) def load_stats(self, stats_file): - # [Fix] stats.pt 파일을 로드하여 정규화 파라미터 확보 stats = torch.load(stats_file, map_location=self.device, weights_only=True) self.param_mean = stats["param_mean"] self.param_std = stats["param_std"] @@ -45,7 +44,6 @@ def process_input(self, q_raw: np.ndarray, R_raw: np.ndarray) -> torch.Tensor: return torch.stack([torch.from_numpy(R_interp.astype(np.float32)), torch.from_numpy(q_valid_mask.astype(np.float32))], dim=0) def normalize_parameters(self, params_real): - # [Fix] 텐서 연산을 위해 mean/std 타입 체크 mean = self.param_mean.numpy() if torch.is_tensor(self.param_mean) else self.param_mean std = self.param_std.numpy() if torch.is_tensor(self.param_std) else self.param_std params_norm = (params_real - mean) / std @@ -74,7 +72,7 @@ def __init__( self.stats_path = Path(stats_file) self.mode = mode self.target_q = qs - # augment_eval=True면 val/test에도 측정 노이즈를 적용해 평가 (expand는 train만) + # with augment_eval=True, val/test get measurement noise too (expand_factor applies to train only) self.augment = augment and (mode == "train" or augment_eval) self.expand_factor = expand_factor if (mode == "train") else 1 self.aug_prob = aug_prob @@ -83,8 +81,8 @@ def __init__( self._setup_split(val_ratio, test_ratio) if self.augment: - # Augmentation은 h5의 원본 q 그리드 위에서 수행되므로 smearing 커널의 - # 픽셀 환산에는 target_q가 아닌 source_q의 간격을 써야 한다. + # augmentation runs on the source q grid of the h5, so the smearing kernel is converted to pixels + # with the spacing of source_q, not target_q. src_q = self.source_q[0] if self.source_q.ndim == 2 else self.source_q self.physics_augmenter = XRRAugmentations( intensity_noise_scale=intensity_scale, @@ -96,7 +94,6 @@ def __init__( self.processor = XRRPreprocessor(self.target_q) - # [Fix] 학습 모드일 때 통계량이 없으면 자동 계산 if mode == "train" and not self.stats_path.exists(): self._calculate_and_save_stats() @@ -116,14 +113,13 @@ def _load_metadata_only(self): self.sld = hf["sld"][:].squeeze() def _calculate_and_save_stats(self): - """[핵심 추가] 학습 데이터셋으로부터 정규화용 통계량(Mean/Std) 계산 및 저장""" print(f"[{self.mode}] Calculating normalization statistics from training subset...") - idx = range(self.train_end) # 학습용 인덱스만 사용 + idx = range(self.train_end) params = np.stack([self.thickness[idx], self.roughness[idx], self.sld[idx]], axis=1) mean = np.mean(params, axis=0) - std = np.std(params, axis=0) + 1e-8 # 0으로 나누기 방지 + std = np.std(params, axis=0) + 1e-8 # avoid division by zero torch.save({"param_mean": torch.from_numpy(mean.astype(np.float32)), "param_std": torch.from_numpy(std.astype(np.float32))}, self.stats_path) print(f" >> Statistics saved to: {self.stats_path}") diff --git a/runs/exp07_fourier_hires/engine/fitting.py b/runs/exp07_fourier_hires/engine/fitting.py index 57291f2..1474560 100644 --- a/runs/exp07_fourier_hires/engine/fitting.py +++ b/runs/exp07_fourier_hires/engine/fitting.py @@ -11,8 +11,6 @@ @dataclass class XRRConfig: - """범용 XRR 분석을 위한 물리학적 파라미터 설정""" - wavelength: float = 1.5406 beam_width: float = 0.1 sample_len_init: float = 10.0 @@ -72,22 +70,21 @@ def add_par(name, val, min_v, max_v, fit=True): self.pars_map[name.replace("v.set_", "set_").replace("v.", "")] = p return p - # [1] Main Film: ±150A 윈도우 전략 add_par("v.set_f_d", d_ai, max(10.0, d_ai * 0.9), d_ai * 1.1) add_par("v.set_f_sig", sig_ai, max(0, sig_ai * 0.9), sig_ai * 1.1) add_par("v.set_f_sld", sld_ai, max(5.0, sld_ai * 0.9), min(150.0, sld_ai * 1.1)) - # [2] Substrate Oxide (SiO2): 저각 위상 조절의 핵심 + # SiO2 substrate oxide: sets the low-angle phase add_par("v.set_s_d", 15.0, 0.01, 55.0) add_par("v.set_s_sig", 3.0, 0.5, 15.0) add_par("v.set_s_sld", 16, 5.0, 20.0) - # [3] Instrument: I0 범위를 넓혀 강도 보상 허용 + # I0 range widened to allow intensity compensation add_par("v.set_i0", 1.0, 0.1, 20.0) add_par("v.set_ibkg", 1e-7, 1e-10, 1e-4) - # [교정] L 파라미터가 40mm를 넘으면 1인치 시료에서 오차가 발생함 - # 실제 시료 크기가 작다면 max_v를 25.0 정도로 줄이는 것이 저각에 유리합니다. + # an L parameter above 40 mm gives errors for a 1 inch sample; for a smaller real sample, + # lowering max_v to about 25 favors low angles add_par("v.set_s_len", cfg.sample_len_init, 5.0, 45.0) model.parameters = pars @@ -98,21 +95,18 @@ def run(self, verbose=True): if verbose: self._print_header() - # [Step 1] INITIAL ANCHORING: I0만 맞추기 # start = time.time() # model.set_fom_func(fom_funcs.R1) # self._set_active_params(["set_i0", "set_ibkg"]) # model.bumps_update_parameters(model.bumps_fit(method="de", steps=200, pop=15)) # if verbose: self._print_status("ANCHORING", time.time() - start) - # [Step 2] MICRO-REFINEMENT: 아주 좁은 범위에서만 허용 # start = time.time() # model.set_fom_func(fom_funcs.logR2) # self._set_active_params(["set_f_d", "set_f_sld", "set_f_sig", "set_i0"]) # model.bumps_update_parameters(model.bumps_fit(method="de", steps=400, pop=cfg.pop_size)) # if verbose: self._print_status("REFINEMENT", time.time() - start) - # [Step 3] SUBSTRATE MATCHING: 산화막 위상 조정 start = time.time() model.set_fom_func(fom_funcs.logR2) self._set_active_params(["set_s_d", "set_s_sig", "set_s_d", "set_i0"]) @@ -120,7 +114,6 @@ def run(self, verbose=True): if verbose: self._print_status("SUBSTRATE", time.time() - start) - # [Step 4] FINAL POLISHING: 전체 미세 보정 start = time.time() self._set_active_params(["set_f_d", "set_f_sld", "set_f_sig", "set_s_d", "set_s_sld", "set_i0", "set_ibkg", "set_s_len"]) model.bumps_update_parameters(model.bumps_fit(method="de", steps=600, pop=cfg.pop_size)) diff --git a/runs/exp07_fourier_hires/engine/inference.py b/runs/exp07_fourier_hires/engine/inference.py index a48c911..129bc15 100644 --- a/runs/exp07_fourier_hires/engine/inference.py +++ b/runs/exp07_fourier_hires/engine/inference.py @@ -11,8 +11,6 @@ class XRRPreprocessor: - """XRR 데이터 전처리 및 역변환을 담당하는 유틸리티 클래스""" - def __init__(self, qs: np.ndarray, stats_file: Path | str | None = None, device: torch.device = torch.device("cpu")): self.target_q = qs self.device = device @@ -28,12 +26,11 @@ def load_stats(self, stats_file: Path | str): self.param_std = stats["param_std"] def process_input(self, q_raw: np.ndarray, R_raw: np.ndarray) -> torch.Tensor: - """실측 데이터(Linear)를 모델 입력용 텐서(Grid-aligned Log)로 변환""" + """Convert measured (linear) data into the model input tensor (grid-aligned log).""" R_raw = np.nan_to_num(R_raw, nan=1e-15, posinf=1e-15, neginf=1e-15) R_norm = i0_normalize(R_raw) R_log = np.log10(np.maximum(R_norm, 1e-15)) - # 정렬 보장 if q_raw[0] > q_raw[-1]: q_raw, R_log = q_raw[::-1], R_log[::-1] @@ -44,7 +41,7 @@ def process_input(self, q_raw: np.ndarray, R_raw: np.ndarray) -> torch.Tensor: return torch.stack([torch.from_numpy(R_interp.astype(np.float32)), torch.from_numpy(q_valid_mask.astype(np.float32))], dim=0) def denormalize(self, params_norm: torch.Tensor) -> np.ndarray: - """모델의 정규화된 출력을 실제 물리량(A, x10^-6 A^-2)으로 변환""" + """Convert normalized model output to physical units (A, x10^-6 A^-2).""" if self.param_mean is None: raise ValueError("Normalization statistics not loaded.") @@ -97,21 +94,18 @@ def _load_model(self): self.model.eval() def predict(self, q_raw: np.ndarray, R_raw: np.ndarray) -> ParamSet: - """AI 추론을 수행하고 ParamSet 객체를 반환""" x = self.processor.process_input(q_raw, R_raw).unsqueeze(0).to(self.device) with torch.no_grad(): y_pred_norm = self.model(x).squeeze(0) y_real = self.processor.denormalize(y_pred_norm) - # 물리적 제약조건 적용 (음수 방지) + # clip to physically valid values (non-negative) y_real = np.maximum(y_real, [1.0, 0.0, 0.0]) return ParamSet(thickness=float(y_real[0]), roughness=float(y_real[1]), sld=float(y_real[2])) def plot_ai_guess(self, q_measured: np.ndarray, R_measured: np.ndarray, ai_params: ParamSet, save_path: Path | None = None): - """AI의 초기 추측값과 실측 데이터를 비교하는 플롯 생성""" - # AI 예측 파라미터로 곡선 생성 R_ai = param2refl(q_measured, [ai_params]) plt.figure(figsize=(10, 6)) diff --git a/runs/exp07_fourier_hires/engine/script_builder.py b/runs/exp07_fourier_hires/engine/script_builder.py index f6c10cc..01fea3f 100644 --- a/runs/exp07_fourier_hires/engine/script_builder.py +++ b/runs/exp07_fourier_hires/engine/script_builder.py @@ -1,5 +1,5 @@ class GenXScriptBuilder: - """GenX 모델 내부에서 실행될 파이썬 스크립트를 생성합니다.""" + """Builds the Python script that runs inside the GenX model.""" def build(self, init_params: dict[str, float]) -> str: return f""" diff --git a/runs/exp07_fourier_hires/evaluate.py b/runs/exp07_fourier_hires/evaluate.py index 05151e0..80539cc 100644 --- a/runs/exp07_fourier_hires/evaluate.py +++ b/runs/exp07_fourier_hires/evaluate.py @@ -19,13 +19,10 @@ apply_origin_style() -# 유니코드 위첨자는 mathtext 폰트에 글리프가 없어 경고가 나므로 mathtext로 표기 +# unicode superscripts have no glyph in the mathtext font and trigger warnings, so mathtext is used PARAM_NAMES = ["Thickness (Å)", "Roughness (Å)", r"SLD (10$^{-6}$ Å$^{-2}$)"] -# ----------------------------------------------------------------------------- -# 1. Advanced Metrics -# ----------------------------------------------------------------------------- def calculate_r2(true, pred): """Calculate R^2 (Coefficient of Determination)""" ss_res = np.sum((true - pred) ** 2, axis=0) @@ -52,9 +49,6 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: } -# ----------------------------------------------------------------------------- -# 2. Reporting & Saving -# ----------------------------------------------------------------------------- def save_results_csv(df: pd.DataFrame, save_path: Path): if not save_path: return @@ -64,11 +58,10 @@ def save_results_csv(df: pd.DataFrame, save_path: Path): def save_worst_cases(df: pd.DataFrame, abs_errors: np.ndarray, save_path: Path, top_k=50): - """가장 에러가 큰 샘플들의 정보를 저장""" if not save_path: return - # 두께 에러 기준으로 정렬 (가장 중요한 물성) + # sorted by thickness error, the most important parameter # Thickness column index assumed 0 thick_errs = abs_errors[:, 0] worst_indices = np.argsort(thick_errs)[::-1][:top_k] @@ -90,11 +83,8 @@ def print_metrics_table(metrics: dict, param_names: list): print("=" * 80) -# ----------------------------------------------------------------------------- -# 3. Visualization (The Ultimate Plotter) -# ----------------------------------------------------------------------------- def _outside_note(ax, frac: float, loc: str = "lower right"): - """축 밖으로 잘려 나간 점의 비율을 그림 안에 남긴다.""" + """Record the fraction of points clipped off the axes in the figure.""" if frac <= 0: return x, ha = (0.99, "right") if "right" in loc else (0.01, "left") @@ -104,9 +94,9 @@ def _outside_note(ax, frac: float, loc: str = "lower right"): def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, title_prefix: str = "", clip_axes: bool = False): """ - clip_axes=True면 소수의 큰 이상치가 축을 늘려 본체를 뭉개는 것을 막는다. - parity는 참값 범위로, 오차 히스토그램은 0.5-99.5 퍼센타일로 자르고 - 잘린 비율을 그림에 표기한다. + clip_axes=True keeps a few large outliers from stretching the axes and squashing the bulk. + Parity is cut to the true-value range, the error histogram to the 0.5-99.5 percentiles, + and the clipped fraction is written on the figure. """ if not save_path: return @@ -121,7 +111,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, err = df[f"{clean}_Error"].values color = ORIGIN_COLORS[i % len(ORIGIN_COLORS)] - # 1. Parity plot - 1:1 기준선은 계열이 아니라 참조선이므로 검은색 + # parity plot; the 1:1 line is a reference, not a series, so it is black ax_s = style_axes(axes[0][i]) min_v = min(y_true.min(), y_pred.min()) max_v = max(y_true.max(), y_pred.max()) @@ -129,7 +119,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, ax_s.scatter(y_true, y_pred, s=10, facecolor=color, edgecolor="none", alpha=0.35) ax_s.plot([min_v, max_v], [min_v, max_v], color=INK, lw=1.2, linestyle="--", label="1:1") - # 지표는 항상 전체 데이터로 계산한다 - 축만 자른다 + # metrics always use the full data; only the axes are clipped r2 = calculate_r2(y_true, y_pred) ax_s.set_title(f"{name} $R^2$ = {r2:.4f}") ax_s.set_xlabel("Ground truth") @@ -145,7 +135,6 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, ax_s.set_aspect("equal", adjustable="box") _outside_note(ax_s, outside) - # 2. Error histogram ax_h = style_axes(axes[1][i]) mu, sigma = np.mean(err), np.std(err) @@ -178,7 +167,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, def save_history_plot(history: dict, save_path: Path, title_prefix: str = ""): - """학습/검증 손실 곡선과 LR 스케줄을 저장""" + """Save training/validation loss curves and the LR schedule.""" train = history.get("train", []) val = history.get("val", []) lrs = history.get("lr", []) @@ -228,30 +217,23 @@ def save_history_plot(history: dict, save_path: Path, title_prefix: str = ""): def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, title_prefix: str = ""): """ - 파라미터 조합(Thickness vs Roughness)에 따른 예측 난이도(MAE) 시각화 + Visualize prediction difficulty (MAE) over parameter combinations (thickness vs roughness). """ - # 1. 구간화 (Binning): 연속적인 파라미터를 10개 구간으로 나눔 - # 데이터의 분포에 따라 bins 숫자를 조절하세요. n_bins = 10 - # 분석용 임시 복사본 생성 및 절대 오차 계산 plot_df = df.copy() plot_df["Abs_Thick_Err"] = plot_df["Thickness_Error"].abs() - # 구간 생성 (pd.cut 사용) plot_df["Thick_Bin"] = pd.cut(plot_df["Thickness_True"], bins=n_bins).apply(lambda x: int(x.mid)) plot_df["Rough_Bin"] = pd.cut(plot_df["Roughness_True"], bins=n_bins).apply(lambda x: int(x.mid)) - # 2. 피벗 테이블 생성 (Index: Roughness, Columns: Thickness, Values: MAE) pivot_table = plot_df.pivot_table(values="Abs_Thick_Err", index="Rough_Bin", columns="Thick_Bin", aggfunc="mean", observed=False) - # 물리적 직관을 위해 Index 정렬 (Roughness가 위로 갈수록 커지게) pivot_table = pivot_table.sort_index(ascending=False) - # 3. 시각화 fig, ax = plt.subplots(figsize=(10, 8)) - # 크기(magnitude)를 나타내므로 단일 색상 ramp를 밝음→어두움으로. 무지개 금지. + # magnitude: one-hue ramp from light to dark, no rainbow sns.heatmap( pivot_table, annot=True, @@ -263,7 +245,7 @@ def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, title_pr ax=ax, ) - # heatmap은 minor tick이 의미 없으므로 프레임만 적용 + # minor ticks mean nothing on a heatmap, so only the frame is styled for spine in ax.spines.values(): spine.set_visible(True) spine.set_linewidth(1.2) @@ -275,7 +257,6 @@ def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, title_pr ax.set_xlabel("True thickness (Å)") ax.set_ylabel("True roughness (Å)") - # 4. 물리적 해석 가이드라인 추가 if save_path: plt.savefig(save_path, dpi=150, bbox_inches="tight") print(f"[Save] Error Heatmap: {save_path.name}") @@ -288,7 +269,7 @@ def plot_single_curve(ax_main, ax_res, qs, R_true, R_pred, title): style_axes(ax_main) style_axes(ax_res) - # 측정값은 검은 실선(참조), 재구성은 계열 색 파선 + # measured data: black solid line (reference); reconstruction: dashed in the series color ax_main.plot(qs, R_true, color=INK, lw=1.2, label="Ground truth") ax_main.plot(qs, R_pred, color=ORIGIN_COLORS[0], lw=1.2, ls="--", label="AI recon.") ax_main.set_yscale("log") @@ -367,9 +348,6 @@ def save_advanced_reconstruction( print(f"[Save] Reconstruction Plot: {save_path.name}") -# ----------------------------------------------------------------------------- -# 4. Pipeline -# ----------------------------------------------------------------------------- def run_inference(model, loader, device): model.eval() preds, targets = [], [] @@ -396,7 +374,6 @@ def evaluate_pipeline( print("❌ Error: Missing checkpoint or stats file.") return - # 1. Load Resources device = torch.device("cuda" if torch.cuda.is_available() else "cpu") stats = torch.load(stats_path, map_location="cpu") @@ -405,16 +382,13 @@ def evaluate_pipeline( # Handle older checkpoint compatibility model_args = config_dict.get("model_args", config_dict) - # 2. Model Init model = XRRPhysicsModel(**model_args).to(device) model.load_state_dict(ckpt["model_state_dict"]) print(f"✅ Model loaded from {checkpoint_path.name}") print(f" (Fourier: {model.use_fourier})") - # 3. Inference preds_norm, targets_norm = run_inference(model, test_loader, device) - # 4. Denormalize p_mean = stats["param_mean"] p_std = stats["param_std"] @@ -424,7 +398,6 @@ def evaluate_pipeline( preds_np = preds_real.numpy() targets_np = targets_real.numpy() - # 5. Metrics & DataFrame metrics = calculate_metrics(preds_np, targets_np) param_names = PARAM_NAMES @@ -437,7 +410,6 @@ def evaluate_pipeline( print_metrics_table(metrics, param_names) - # 6. Save Reports if report_csv_path: save_results_csv(df, report_csv_path) # Save worst cases for debugging @@ -455,7 +427,6 @@ def evaluate_pipeline( plot_error_heatmap(df, save_path=report_img_path.parent / "error_heatmap.png") - # 7. History Plot if report_history_path: history = ckpt.get("history", {}) if history: @@ -466,31 +437,24 @@ def evaluate_pipeline( return metrics -# ----------------------------------------------------------------------------- -# 5. Main Entry Point for Standalone Evaluation -# ----------------------------------------------------------------------------- def main(): print(f"=== Starting Evaluation for Experiment: {CONFIG['exp_name']} ===") - # 1. 경로 설정 (main.py의 로직과 동기화) exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] h5_file = exp_dir / "dataset.h5" stats_file = exp_dir / "stats.pt" - checkpoint_file = exp_dir / "best.pt" # 혹은 "last.pt" + checkpoint_file = exp_dir / "best.pt" if not checkpoint_file.exists(): print(f"❌ Error: Checkpoint not found at {checkpoint_file}") return - # 2. Q-Vector 생성 (물리 모델 계산용) qs = np.linspace( CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], ) - # 3. Test DataLoader 준비 - # evaluate 모드이므로 augmentation은 끄고 데이터 로드만 수행 print("Preparing Test DataLoader...") test_ds = XRR1LayerDataset( qs=qs, @@ -499,7 +463,7 @@ def main(): mode="test", val_ratio=CONFIG["training"]["val_ratio"], test_ratio=CONFIG["training"]["test_ratio"], - augment=False, # 평가 시에는 원본 데이터 사용 + augment=False, ) test_loader = DataLoader( @@ -510,8 +474,6 @@ def main(): pin_memory=torch.cuda.is_available(), ) - # 4. Pipeline 실행 - # 결과물들은 실험 폴더(exp_dir) 내부로 저장되도록 경로 설정 evaluate_pipeline( test_loader=test_loader, checkpoint_path=checkpoint_file, @@ -526,7 +488,6 @@ def main(): if __name__ == "__main__": - # 실행 시 GPU 메모리 효율화를 위해 세팅 if torch.cuda.is_available(): torch.backends.cudnn.benchmark = True diff --git a/runs/exp07_fourier_hires/main.py b/runs/exp07_fourier_hires/main.py index 8055e29..73849a7 100644 --- a/runs/exp07_fourier_hires/main.py +++ b/runs/exp07_fourier_hires/main.py @@ -31,7 +31,6 @@ def ensure_data_exists(qs, config, h5_path): def get_dataloaders(qs, config, h5_file, stats_file): t_cfg = config["training"] - # 우리가 수정한 Physics-Augmentation 파라미터들 common_args = { "qs": qs, "h5_file": h5_file, @@ -41,14 +40,13 @@ def get_dataloaders(qs, config, h5_file, stats_file): "aug_prob": t_cfg["aug_prob"], "intensity_scale": t_cfg["intensity_scale"], "q_shift_sigma": t_cfg["q_shift_sigma"], - # [핵심] 77A 방지용 Resolution Smearing 범위 (물리적 q 단위) + # resolution smearing range, in physical q units "res_sigma_range": tuple(t_cfg.get("res_sigma_range", (0.0001, 0.006))), "augment_eval": t_cfg.get("augment_eval", False), } loaders = [] for mode in ["train", "val", "test"]: - # shuffle은 학습 데이터에만 적용 ds = XRR1LayerDataset(**common_args, mode=mode, val_ratio=t_cfg["val_ratio"], test_ratio=t_cfg["test_ratio"]) loaders.append( @@ -58,7 +56,7 @@ def get_dataloaders(qs, config, h5_file, stats_file): shuffle=(mode == "train"), num_workers=t_cfg["num_workers"], pin_memory=torch.cuda.is_available(), - drop_last=(mode == "train"), # Batch norm 안정성 + drop_last=(mode == "train"), # keeps batch norm stable ) ) return loaders @@ -66,8 +64,8 @@ def get_dataloaders(qs, config, h5_file, stats_file): def register_nan_hooks(model): """ - 모델의 모든 레이어에 NaN/Inf 감지 훅을 설치합니다. - 문제가 발생하면 즉시 레이어 이름과 값을 출력하고 프로그램을 멈춥니다. + Install NaN/Inf detection hooks on every layer of the model. + On a problem the layer name and values are printed and the program stops. """ def forward_hook(module, input, output): @@ -77,11 +75,10 @@ def forward_hook(module, input, output): raise RuntimeError(f"NaN found in output of {module}") if torch.isinf(output).any(): print(f"⚠️ [Inf Detected] Forward Pass - Layer: {module}") - # Inf는 즉시 에러는 아니지만 NaN의 전조증상임 + # Inf is not an immediate error but a precursor of NaN print(f" - Max val: {output.max().item()}, Min val: {output.min().item()}") def backward_hook(module, grad_input, grad_output): - # grad_output: 이 레이어에서 나가는 그라디언트 if grad_output is not None: for _, grad in enumerate(grad_output): if isinstance(grad, torch.Tensor): @@ -93,7 +90,7 @@ def backward_hook(module, grad_input, grad_output): print("🔎 Installing NaN hooks on all layers...") for _, module in model.named_modules(): - # 컨테이너(Sequential 등)가 아닌 실제 연산 레이어에만 등록 + # register on actual compute layers only, not on containers such as Sequential if len(list(module.children())) == 0: module.register_forward_hook(forward_hook) module.register_full_backward_hook(backward_hook) @@ -103,24 +100,20 @@ def register_debug_hooks(model): print("🕵️‍♀️ Installing Debug Hooks (Input/Weight Inspector)...") def forward_hook(module, input, output): - # input은 튜플로 들어옵니다 (x, ) x = input[0] - # 1. 입력 데이터 검사 if torch.isnan(x).any() or torch.isinf(x).any(): print(f"\n🚨 [CRITICAL] Input is dirty BEFORE entering {module}") print(f" - Input min: {x.min().item()}, max: {x.max().item()}") print(f" - Input NaNs: {torch.isnan(x).sum().item()}") raise RuntimeError(f"Bad Input at {module}") - # 2. 가중치(Weights) 검사 (Conv/Linear 등) if hasattr(module, "weight") and module.weight is not None: if torch.isnan(module.weight).any() or torch.isinf(module.weight).any(): print(f"\n💀 [CRITICAL] Weights are ALREADY broken at {module}") print(f" - Weight min: {module.weight.min().item()}, max: {module.weight.max().item()}") raise RuntimeError(f"Broken Weights at {module}") - # 3. 출력 결과 검사 (여기가 터지면 연산 중 폭발) if isinstance(output, torch.Tensor): if torch.isnan(output).any() or torch.isinf(output).any(): print(f"\n💥 [CRITICAL] Output exploded AFTER {module}") @@ -130,7 +123,6 @@ def forward_hook(module, input, output): raise RuntimeError(f"Explosion at {module}") for _, module in model.named_modules(): - # 컨테이너가 아닌 실제 연산 레이어에만 훅 등록 if len(list(module.children())) == 0: module.register_forward_hook(forward_hook) @@ -138,7 +130,6 @@ def forward_hook(module, input, output): def main(): print("🚀 EXP07 Launching: Physics-Informed Fourier Network") set_seed(42) - # 1. 경로 및 설정 저장 exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] exp_dir.mkdir(parents=True, exist_ok=True) h5_file = exp_dir / "dataset.h5" @@ -146,17 +137,14 @@ def main(): checkpoint_file = exp_dir / "best.pt" qs_file = exp_dir / "qs.npy" - # 2. Grid 생성 (학습의 기준점) - # [Tip] 재학습 시 Cubic Spline을 쓰므로 포인트를 2000개로 넉넉히 유지 + # keep a generous 2000 points because retraining uses a cubic spline sim_cfg = CONFIG["simulation"] qs = np.linspace(sim_cfg["q_min"], sim_cfg["q_max"], sim_cfg["q_points"]).astype(np.float32) np.save(qs_file, qs) save_config(CONFIG, exp_dir / "config.json") - # 3. 데이터 준비 및 로드 ensure_data_exists(qs, CONFIG, h5_file) train_loader, val_loader, test_loader = get_dataloaders(qs, CONFIG, h5_file, stats_file) - # 4. 모델 초기화 print("🧠 Initializing Fourier Physics Network...") m_cfg = CONFIG["model"] model = XRRPhysicsModel( @@ -171,11 +159,10 @@ def main(): fourier_scale=m_cfg["fourier_scale"], ) - # Debug mode (config["debug"]["hooks"] = True 일 때만; 학습 속도 손실 큼) + # debug hooks, only when config["debug"]["hooks"] is True; they slow training a lot if CONFIG.get("debug", {}).get("hooks", False): register_debug_hooks(model) - # 5. Trainer 실행 trainer = Trainer( model, train_loader, @@ -190,7 +177,6 @@ def main(): resume_path = exp_dir / "last.pt" trainer.train(CONFIG["training"]["epochs"], resume_from=resume_path if resume_path.exists() else None) - # 6. 최종 평가 (Physics Report 생성) print("\n🏁 Running Final Physics-based Evaluation...") if checkpoint_file.exists(): evaluate_pipeline( diff --git a/runs/exp07_fourier_hires/make_report_figures.py b/runs/exp07_fourier_hires/make_report_figures.py index 77a7adb..5f76e98 100644 --- a/runs/exp07_fourier_hires/make_report_figures.py +++ b/runs/exp07_fourier_hires/make_report_figures.py @@ -1,12 +1,13 @@ """ -발표/보고서용 그림 뽑기 (OriginLab 스타일). +Figures for talks and reports (OriginLab style). -각 모델의 성능 그림을 독립적으로 만든다. variant 간 비교 그림은 plot_ablation.py 쪽. +Each model's performance figures are made independently; comparison figures between variants +are in plot_ablation.py. -추론을 다시 돌리지 않는다 - ablation 평가가 남긴 evaluation_results_*.csv에 -True/Pred/Error가 이미 들어 있으므로 그림만 다시 그린다. 학습곡선만 best.pt에서 읽는다. +Inference is not rerun: evaluation_results_*.csv left by the ablation evaluation already holds +True/Pred/Error, so only the figures are redrawn. Only the training curves are read from best.pt. -사용: +Usage: python runs/exp07_fourier_hires/make_report_figures.py python runs/exp07_fourier_hires/make_report_figures.py --only exp07 exp02 """ @@ -31,9 +32,9 @@ apply_origin_style() -# 출력 이름 -> ablation variant 폴더 -# exp02는 원본 데이터가 소실되어 재현 불가하므로 동등 조건 variant로 대체한다 -# (q 200점, Fourier 없음, augmentation 없음). +# output name -> ablation variant folder. +# exp02's source data is lost and cannot be reproduced, so an equivalent variant stands in +# (200 q points, no Fourier, no augmentation). LABELS: dict[str, str] = { "current": "A_full", "previous": "E_exp02_like", @@ -42,8 +43,8 @@ "current_lowres": "D_lowres_200", } -# 그림 안에 찍히는 이름. 발표는 이전 모델 / 현재 모델 대비로 구성하므로 -# 실험 번호 대신 Previous / Current로 표기한다. +# names printed in the figures. The talk compares the previous and the current model, +# so Previous / Current is used instead of experiment numbers. DISPLAY: dict[str, str] = { "current": "Current", "previous": "Previous", @@ -56,7 +57,7 @@ def metrics_from_df(df: pd.DataFrame) -> pd.DataFrame: - """평가 CSV에서 파라미터별 지표 재계산.""" + """Recompute per-parameter metrics from the evaluation CSV.""" rows = [] for name in PARAM_NAMES: clean = name.split(" (")[0] @@ -77,8 +78,8 @@ def metrics_from_df(df: pd.DataFrame) -> pd.DataFrame: def plot_error_vs_thickness(df: pd.DataFrame, save_path: Path, title: str): """ - 두께에 따른 절대 오차. 어느 두께 구간에서 모델이 무너지는지 보여준다. - 점이 수천 개라 그대로 뿌리면 뭉치므로, 구간 중앙값과 사분위 범위를 함께 그린다. + Absolute error against thickness; shows in which thickness range the model breaks down. + With thousands of points a raw scatter clumps, so the bin median and interquartile range are drawn too. """ thick = df["Thickness_True"].to_numpy() abs_err = np.abs(df["Thickness_Error"].to_numpy()) @@ -108,8 +109,8 @@ def plot_error_vs_thickness(df: pd.DataFrame, save_path: Path, title: str): ax.plot(centers, med, color=ORIGIN_COLORS[0], marker="o", ms=5, mec=INK, mew=0.6, zorder=3, label="Median") ax.set_yscale("log") - # 0에 가까운 소수의 오차가 축을 6 decade로 늘려 중앙값/IQR을 짓눌러 버린다. - # 하한을 1 퍼센타일로 잘라내되, 잘린 비율을 그림 안에 밝힌다. + # a few errors near 0 stretch the axis over 6 decades and flatten the median/IQR. + # Clip the lower bound at the 1st percentile and state the clipped fraction in the figure. floor = np.percentile(abs_err, 1) below = float(np.mean(abs_err < floor) * 100) ax.set_ylim(bottom=floor, top=abs_err.max() * 1.5) @@ -157,7 +158,7 @@ def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, domai m.insert(0, "domain", domain) summary.append(m) - # 학습곡선은 체크포인트의 history에서 + # training curves come from the checkpoint history ckpt_path = var_dir / "best.pt" if ckpt_path.exists(): ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) @@ -165,7 +166,7 @@ def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, domai if history: save_history_plot(history, out_dir / f"{label}_learning_curve.png", title_prefix=shown) - # 곡선 재구성 그림은 GenX 시뮬레이션이 필요하므로 기존 산출물을 가져온다 + # reconstruction figures need a GenX simulation, so existing outputs are reused recon = var_dir / "reconstruction_analysis.png" if recon.exists(): shutil.copy2(recon, out_dir / f"{label}_reconstruction.png") @@ -202,8 +203,8 @@ def main(): ablation_dir = root / "ablation" out_dir = ablation_dir / "report_figures" if args.clean and out_dir.exists(): - # 파일 수는 세지 않는다 - 다른 프로세스가 방금 쓴 디렉터리를 열거하면 - # 0이 나오는 경우가 있어 지운 개수를 신뢰할 수 없다. 삭제 자체는 정상. + # the file count is not reported: listing a directory another process just wrote + # sometimes returns 0, so the deleted count is unreliable. The deletion itself is fine. shutil.rmtree(out_dir) print(f"[Clean] cleared {out_dir}") out_dir.mkdir(parents=True, exist_ok=True) @@ -216,7 +217,7 @@ def main(): def write_readme(out_dir: Path, labels: list[str], domains: list[str]): - """그림이 무엇을 재는지, 무엇을 재지 않는지 함께 둔다.""" + """Write what the figures measure and what they do not.""" lines = [ "# Report figures", "", diff --git a/runs/exp07_fourier_hires/metrics_report.py b/runs/exp07_fourier_hires/metrics_report.py index 71ee887..12b493c 100644 --- a/runs/exp07_fourier_hires/metrics_report.py +++ b/runs/exp07_fourier_hires/metrics_report.py @@ -1,18 +1,18 @@ """ -과제 보고용 확장 지표 산출기. +Extended metrics for the assignment report. -추론을 다시 돌리지 않는다 - ablation 평가가 남긴 evaluation_results_{tag}.csv에는 -샘플별 True/Pred/Error가 그대로 있으므로 거기서 전부 계산한다. -추론 속도만 예외로, 실제 모델을 올려 측정한다(--benchmark). +Inference is not rerun: evaluation_results_{tag}.csv from the ablation evaluation holds +per-sample True/Pred/Error, so everything is computed from it. +Inference speed is the exception; it is measured with a real model (--benchmark). -산출물 (ablation/metrics/): - metrics_extended_{tag}.csv 파라미터별 MAE/RMSE/MAPE/MedAE/P90/P95/bias/SD/R2 - metrics_tolerance_{tag}.csv 허용오차 내 샘플 비율 - metrics_by_thickness_{tag}.csv 두께 구간별 분해 - metrics_cost.csv 파라미터 수 / 학습 시간 / 수렴 epoch / 추론 처리량 - METRICS.md 위 표를 마크다운으로 (과제에 붙여넣기용) +Outputs (ablation/metrics/): + metrics_extended_{tag}.csv per-parameter MAE/RMSE/MAPE/MedAE/P90/P95/bias/SD/R2 + metrics_tolerance_{tag}.csv fraction of samples within a tolerance + metrics_by_thickness_{tag}.csv breakdown by thickness bin + metrics_cost.csv parameter count / training time / convergence epoch / inference throughput + METRICS.md the tables above as markdown (to paste into the assignment) -사용: +Usage: python runs/exp07_fourier_hires/metrics_report.py python runs/exp07_fourier_hires/metrics_report.py --benchmark python runs/exp07_fourier_hires/metrics_report.py --only A_full E_exp02_like @@ -27,20 +27,20 @@ from ablation import VARIANTS, build_qs, deep_merge, make_loaders from config import CONFIG -# 보고서에서 previous/current로 부르는 쌍. 나머지는 ablation 요소로만 등장한다. +# the pair called previous/current in the report. The rest appear only as ablation factors. REPORT_LABELS = {"E_exp02_like": "previous (exp02-like)", "A_full": "current (exp07)"} -# (csv 접두사, 표시 이름, 단위) +# (csv prefix, display name, unit) PARAMS = [ ("Thickness", "Thickness", "Å"), ("Roughness", "Roughness", "Å"), ("SLD", "SLD", "1e-6 Å⁻²"), ] -# 두께 구간. config의 param_ranges (10-1200 Å)를 물리적으로 의미 있는 구간으로 나눈다. +# thickness bins: the config param_ranges (10-1200 A) split into physically meaningful ranges. THICKNESS_BANDS = [(10, 50), (50, 100), (100, 300), (300, 600), (600, 1200)] -# 허용오차 정의. 상대(%)와 절대(단위) 중 파라미터 성격에 맞는 쪽을 쓴다. +# tolerance definition: relative (%) or absolute (unit), whichever suits the parameter. TOLERANCES = { "Thickness": [("rel", 1.0), ("rel", 5.0), ("rel", 10.0), ("abs", 5.0), ("abs", 10.0)], "Roughness": [("abs", 0.5), ("abs", 1.0), ("abs", 2.0)], @@ -77,7 +77,7 @@ def core_metrics(true: np.ndarray, pred: np.ndarray) -> dict: def load_eval(var_dir: Path, tag: str) -> pd.DataFrame | None: path = var_dir / f"evaluation_results_{tag}.csv" if not path.exists(): - # 학습 직후 저장되는 무접미사 파일(clean 도메인)을 대체로 허용 + # also accept the suffix-less file written right after training (clean domain) alt = var_dir / "evaluation_results.csv" if tag == "clean" and alt.exists(): return pd.read_csv(alt) @@ -85,9 +85,6 @@ def load_eval(var_dir: Path, tag: str) -> pd.DataFrame | None: return pd.read_csv(path) -# ----------------------------------------------------------------------------- -# 표 만들기 -# ----------------------------------------------------------------------------- def build_extended(dfs: dict[str, pd.DataFrame]) -> pd.DataFrame: rows = [] for variant, df in dfs.items(): @@ -142,11 +139,8 @@ def build_by_thickness(dfs: dict[str, pd.DataFrame]) -> pd.DataFrame: return pd.DataFrame(rows) -# ----------------------------------------------------------------------------- -# 비용 / 속도 -# ----------------------------------------------------------------------------- def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, n_batches: int = 30) -> dict: - """test 로더로 실제 추론 처리량을 잰다. 데이터 로딩을 빼고 순수 forward만 잰다.""" + """Measure real inference throughput on the test loader: forward only, data loading excluded.""" import time import torch @@ -167,7 +161,7 @@ def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, n_batch (loader,) = make_loaders(qs, cfg, h5_file, var_dir / "stats.pt", modes=["test"], num_workers=0) - # 배치를 미리 GPU로 올려두고 forward만 반복 측정한다 + # move the batches to the GPU beforehand and time only the forward passes batches = [] for i, (X, _) in enumerate(loader): batches.append(X.to(device)) @@ -177,7 +171,7 @@ def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, n_batch return {} with torch.inference_mode(): - for _ in range(3): # warm-up (cudnn autotune 포함) + for _ in range(3): # warm-up (includes cudnn autotune) model(batches[0]) if device.type == "cuda": torch.cuda.synchronize() @@ -232,7 +226,7 @@ def build_cost(variants: list[str], ablation_dir: Path, h5_file: Path, benchmark if col in summary.columns: row[col] = s[col] - # ablation_summary에 train_seconds가 없는 구버전 실행분은 로그 파일에서 보완 + # older runs without train_seconds in ablation_summary are filled in from the log file time_log = var_dir / "train_time.json" if row.get("train_seconds") is None and time_log.exists(): row["train_seconds"] = json.loads(time_log.read_text())["cumulative_seconds"] @@ -247,11 +241,8 @@ def build_cost(variants: list[str], ablation_dir: Path, h5_file: Path, benchmark return pd.DataFrame(rows) -# ----------------------------------------------------------------------------- -# 마크다운 리포트 -# ----------------------------------------------------------------------------- def fmt(df: pd.DataFrame, floatfmt: str = "{:.4g}") -> str: - """pandas.to_markdown은 tabulate 의존이라 직접 만든다.""" + """pandas.to_markdown depends on tabulate, so the table is built by hand.""" out = df.copy() for c in out.columns: if pd.api.types.is_float_dtype(out[c]): diff --git a/runs/exp07_fourier_hires/origin_style.py b/runs/exp07_fourier_hires/origin_style.py index 8b63071..8a85989 100644 --- a/runs/exp07_fourier_hires/origin_style.py +++ b/runs/exp07_fourier_hires/origin_style.py @@ -1,22 +1,22 @@ """ -OriginLab 스타일 matplotlib 설정. +OriginLab-style matplotlib settings. -Origin 기본 플롯의 시각적 규약을 따른다: - - 사방이 닫힌 검은 프레임(box), 안쪽을 향한 major/minor tick - - grid 없음 (Origin 기본값) - - Arial 계열 sans-serif - - 검은 테두리의 채워진 심볼 - - 테두리가 있는 불투명 범례 +Follows the visual conventions of Origin's default plots: + - closed black frame (box), major/minor ticks pointing inward + - no grid (Origin default) + - Arial-family sans-serif + - filled symbols with black edges + - opaque legend with a border -색은 계열(entity)에 고정 순서로 배정하고 절대 순환시키지 않는다. 아래 순서는 -색각이상 분리도 검증을 통과한 것이므로(최악 인접쌍 protan ΔE 24.9) 순서를 바꾸지 말 것. -검은색은 계열 색이 아니라 축/텍스트/기준선 전용이다. +Colors are assigned to series in a fixed order and never cycled. The order below passed a +color-vision-deficiency separation check (worst adjacent pair protan delta E 24.9), so do not +reorder it. Black is reserved for axes, text and reference lines, not for series. """ import matplotlib as mpl import matplotlib.pyplot as plt -# 계열 색: 고정 순서. 5개를 넘기면 색을 만들어내지 말고 facet으로 쪼갤 것. +# series colors: fixed order. Beyond 5, split into facets instead of inventing colors. ORIGIN_COLORS: list[str] = [ "#C00000", # red "#0070C0", # blue @@ -25,19 +25,17 @@ "#E08B00", # orange ] -# 색과 짝지어 쓰는 2차 인코딩(색각이상/흑백 인쇄 대비). Origin 기본 심볼 순서. +# secondary encoding paired with color (color-vision deficiency, grayscale print). Origin's default symbol order. ORIGIN_MARKERS: list[str] = ["s", "o", "^", "D", "v"] -# 축, 텍스트, 기준선 전용 +# reserved for axes, text and reference lines INK = "#000000" REFERENCE = "#000000" def apply_origin_style() -> None: - """전역 rcParams에 Origin 스타일 적용.""" mpl.rcParams.update( { - # --- Font --- "font.family": "sans-serif", "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], "font.size": 11, @@ -47,13 +45,11 @@ def apply_origin_style() -> None: "ytick.labelsize": 11, "legend.fontsize": 10, "mathtext.fontset": "dejavusans", - # --- Frame: 사방이 닫힌 박스 --- "axes.linewidth": 1.2, "axes.edgecolor": INK, "axes.labelcolor": INK, "axes.spines.top": True, "axes.spines.right": True, - # --- Ticks: 안쪽, 사방 --- "xtick.direction": "in", "ytick.direction": "in", "xtick.top": True, @@ -70,21 +66,17 @@ def apply_origin_style() -> None: "ytick.minor.width": 1.0, "xtick.minor.visible": True, "ytick.minor.visible": True, - # --- Grid: Origin 기본은 없음 --- "axes.grid": False, - # --- Marks --- "lines.linewidth": 1.5, "lines.markersize": 6.0, "lines.markeredgewidth": 1.0, "axes.prop_cycle": mpl.cycler(color=ORIGIN_COLORS), - # --- Legend: 테두리 있는 불투명 박스 --- "legend.frameon": True, "legend.edgecolor": INK, "legend.facecolor": "white", "legend.framealpha": 1.0, "legend.fancybox": False, "legend.borderpad": 0.4, - # --- Surface --- "figure.facecolor": "white", "axes.facecolor": "white", "savefig.facecolor": "white", @@ -96,7 +88,7 @@ def apply_origin_style() -> None: def style_axes(ax: plt.Axes, minor: bool = True) -> plt.Axes: - """개별 Axes에 프레임/tick 규약 적용. rcParams를 우회하는 플롯(seaborn 등)용.""" + """Apply the frame/tick conventions to one Axes; for plots that bypass rcParams (seaborn etc.).""" for spine in ax.spines.values(): spine.set_visible(True) spine.set_linewidth(1.2) @@ -111,7 +103,7 @@ def style_axes(ax: plt.Axes, minor: bool = True) -> plt.Axes: def series_style(index: int) -> dict: - """계열 index에 대한 색 + 심볼. 색만으로 구분되지 않도록 항상 함께 쓴다.""" + """Color and symbol for a series index. Always used together so series are not told apart by color alone.""" return { "color": ORIGIN_COLORS[index % len(ORIGIN_COLORS)], "marker": ORIGIN_MARKERS[index % len(ORIGIN_MARKERS)], diff --git a/runs/exp07_fourier_hires/plot_ablation.py b/runs/exp07_fourier_hires/plot_ablation.py index e11f686..6ad4389 100644 --- a/runs/exp07_fourier_hires/plot_ablation.py +++ b/runs/exp07_fourier_hires/plot_ablation.py @@ -1,11 +1,12 @@ """ -Ablation 결과 비교 그림 (OriginLab 스타일). +Ablation comparison figures (OriginLab style). -clean / augmented 두 평가 도메인의 요약 CSV를 읽어 리포트용 그림을 만든다. -두 도메인을 함께 그리는 것이 핵심이다 - clean 평가만 보면 augmentation 없이 학습한 -variant가 유리해 보이는데(학습/평가 분포 일치), 이는 일반화 성능이 아니다. +Reads the summary CSVs of the clean and augmented evaluation domains and makes report figures. +Both domains must be drawn together: on clean evaluation alone, variants trained without +augmentation look better (train and eval distributions match), which is not generalization +performance. -사용: +Usage: python runs/exp07_fourier_hires/plot_ablation.py """ @@ -31,8 +32,8 @@ ("SLD", r"SLD (10$^{-6}$ Å$^{-2}$)", r"MAE (10$^{-6}$ Å$^{-2}$)"), ] -# 도메인은 2개뿐이므로 계열 색 2개만 사용 (고정 순서). -# 심볼도 함께 바꿔 색만으로 구분되지 않게 한다. +# only two domains, so only two series colors (fixed order). +# Symbols differ too, so the series are not told apart by color alone. DOMAIN_STYLE = { "clean": {"color": ORIGIN_COLORS[1], "marker": "o", "label": "Clean simulation"}, "augmented": {"color": ORIGIN_COLORS[0], "marker": "s", "label": "With measurement noise"}, @@ -42,12 +43,12 @@ def _dumbbell(ax, y, lo, hi): - """두 도메인 값을 잇는 연결선. 값이 아니라 이동량을 읽게 하는 보조선.""" + """Connector between the two domain values; a guide that shows the shift rather than the values.""" ax.plot([lo, hi], [y, y], color=CONNECTOR, lw=1.2, zorder=1) def load_summaries(ablation_dir: Path) -> dict[str, pd.DataFrame]: - """도메인별 요약 CSV 로드. 없으면 조용히 건너뛴다.""" + """Load the per-domain summary CSVs; missing ones are skipped silently.""" candidates = { "clean": ["ablation_summary_clean.csv", "ablation_summary.csv"], "augmented": ["ablation_summary_augmented.csv"], @@ -67,17 +68,17 @@ def load_summaries(ablation_dir: Path) -> dict[str, pd.DataFrame]: def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): """ - 파라미터별 MAE를 variant × 평가도메인 dumbbell 점 그래프로. + Per-parameter MAE as a variant x evaluation-domain dumbbell dot plot. - 막대를 쓰지 않는 이유: 값의 범위가 두 자릿수를 넘어 로그 축이 필요한데, - 로그 축 위의 막대는 길이가 크기에 비례하지 않아 잘못 읽힌다. + Why not bars: the values span more than two decades and need a log axis, and bar length on a + log axis is not proportional to magnitude, so it misreads. """ if not dfs: return order = list(next(iter(dfs.values()))["variant"]) domains = [d for d in ("clean", "augmented") if d in dfs] - y = np.arange(len(order))[::-1] # 첫 variant가 위로 오도록 + y = np.arange(len(order))[::-1] # first variant on top fig, axes = plt.subplots(1, len(PARAMS), figsize=(4.9 * len(PARAMS), 4.2), sharey=True) @@ -117,7 +118,7 @@ def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): axes[0].set_yticks(y) axes[0].set_yticklabels(order) - # 패널 안에 두면 데이터 점을 가리므로 그림 상단에 가로로 배치 + # placed horizontally above the figure; inside the panel it would cover data points handles, labels = axes[0].get_legend_handles_labels() fig.legend(handles, labels, loc="upper center", ncol=len(labels), bbox_to_anchor=(0.5, 0.955)) fig.suptitle("Ablation: mean absolute error by variant and evaluation domain", fontsize=13, y=1.06) @@ -129,8 +130,8 @@ def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): def plot_domain_gap(dfs: dict[str, pd.DataFrame], save_path: Path): """ - clean → augmented 로 도메인이 바뀔 때 오차가 몇 배 늘어나는지. - augmentation의 효과가 가장 직접적으로 드러나는 지표다. + How many times the error grows when the domain changes from clean to augmented. + The most direct measure of the effect of augmentation. """ if not {"clean", "augmented"} <= dfs.keys(): print("[Skip] domain gap plot: needs both clean and augmented summaries") @@ -181,7 +182,7 @@ def plot_domain_gap(dfs: dict[str, pd.DataFrame], save_path: Path): def plot_r2_panel(dfs: dict[str, pd.DataFrame], save_path: Path): - """R²는 1에 붙어 있으므로 1−R²를 로그 축으로 그려야 차이가 보인다.""" + """R2 sits near 1, so 1 - R2 is drawn on a log axis to make differences visible.""" if not dfs: return @@ -221,7 +222,7 @@ def plot_r2_panel(dfs: dict[str, pd.DataFrame], save_path: Path): ax.set_ylim(-0.6, len(order) - 0.4) ax.set_title("Unexplained variance in thickness (lower is better)") ax.tick_params(axis="y", which="minor", left=False, right=False) - # 상단 두 행은 x가 작아 우상단이 비어 있다 (하단은 E의 노이즈 값이 차지) + # the top two rows have small x, so the upper right is empty (the bottom row is taken by E's noise values) ax.legend(loc="upper right") plt.tight_layout() diff --git a/runs/exp07_fourier_hires/simulate.py b/runs/exp07_fourier_hires/simulate.py index 531e214..7501eef 100644 --- a/runs/exp07_fourier_hires/simulate.py +++ b/runs/exp07_fourier_hires/simulate.py @@ -7,23 +7,21 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): """ - 1-layer XRR 데이터 생성 + Generate 1-layer XRR data. Args: - config: main.py의 CONFIG 딕셔너리 (simulation, paths, param_ranges 포함) + config: CONFIG dict of main.py (simulation, paths, param_ranges) """ print("=== 1-Layer XRR 데이터 생성 시작 ===") - # config에서 모든 파라미터 추출 simulation = config["simulation"] param_ranges = config["param_ranges"] h5_file = Path(h5_file) - # 출력 디렉토리 생성 output_dir = h5_file.parent output_dir.mkdir(exist_ok=True, parents=True) simulator_args: dict = {"qs": qs, "n_layers": 1, "n_samples": simulation["n_samples"], "has_noise": False} - # config의 param_ranges 키 -> XRRSimulator 인자명 매핑 + # map config param_ranges keys to XRRSimulator argument names range_keys = { "thickness": "thickness_range", "roughness": "roughness_range", diff --git a/runs/exp07_fourier_hires/train.py b/runs/exp07_fourier_hires/train.py index 129aa0d..a5161d9 100644 --- a/runs/exp07_fourier_hires/train.py +++ b/runs/exp07_fourier_hires/train.py @@ -10,8 +10,8 @@ class LogMSELoss(nn.Module): """ - XRR 데이터의 넓은 다이내믹 레인지를 고려한 손실 함수. - 이미 입력이 Log 스케일이라면 가중치를 고각에 더 줄 수 있습니다. + Loss for the wide dynamic range of XRR data. + If the input is already on a log scale, high angles can be weighted more. """ def __init__(self): @@ -19,8 +19,8 @@ def __init__(self): self.mse = nn.MSELoss() def forward(self, preds, targets): - # 파라미터 간의 Scale 차이를 보정하기 위해 기본 MSE 사용 (이미 정규화됨) - # 만약 특정 파라미터(예: 두께)에 가중치를 주고 싶다면 여기서 조정 + # plain MSE: the parameters are already normalized, which handles their scale differences + # weight a specific parameter (e.g. thickness) here if wanted return self.mse(preds, targets) @@ -31,7 +31,7 @@ def __init__( train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, - lr: float = 2e-4, # Fourier Feature 모델은 조금 낮은 LR이 안정적임 + lr: float = 2e-4, # a slightly lower LR is stable for the Fourier feature model weight_decay: float = 1e-4, patience: int = 20, ): @@ -44,7 +44,6 @@ def __init__( self.checkpoint_dir = Path(save_dir) self.checkpoint_dir.mkdir(parents=True, exist_ok=True) - # AdamW: L2 정규화가 더 물리적으로 올바르게 작용함 self.optimizer = optim.AdamW(self.model.parameters(), lr=lr, weight_decay=weight_decay) # Loss Function @@ -59,7 +58,6 @@ def __init__( self.history = {"train": [], "val": [], "lr": []} - # Mixed Precision Training (성능 및 속도 향상) # self.scaler = torch.amp.GradScaler('cuda') if self.device.type == 'cuda' else None self.scaler = None print(f"🚀 Trainer initialized on {self.device}") @@ -88,7 +86,6 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.optimizer.zero_grad(set_to_none=True) # [Physics-Informed Training] - # autocast를 사용하여 연산 효율 증대 if self.scaler: with torch.amp.autocast("cuda"): preds = self.model(inputs) @@ -126,7 +123,6 @@ def _validate(self) -> float: def train(self, epochs: int, resume_from: str | Path | None = None): start_epoch = self._load_checkpoint(resume_from) if resume_from else 1 - # Dataset이 expand_factor를 가질 경우 실제 학습 샘플 수 출력 train_size = len(self.train_loader.dataset) print(f"\n[Training Start] Total Samples: {train_size} (Expanded)") print("-" * 70) @@ -188,9 +184,9 @@ def _load_checkpoint(self, filepath: str | Path) -> int: self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"]) self.history = checkpoint.get("history", self.history) - # last.pt의 'val_loss'는 마지막 epoch 값이지 최저값이 아니다. 그대로 쓰면 - # 재개 후 기준이 느슨해져 더 나쁜 모델이 best.pt를 덮어쓴다. - # 저장된 best_val_loss를 우선 쓰고, 없으면(구버전 ckpt) history에서 복원한다. + # val_loss in last.pt is the last epoch's value, not the minimum. Using it would loosen the criterion + # after a resume, and a worse model would overwrite best.pt. + # Prefer the saved best_val_loss; if absent (older ckpt), recover it from the history. best = checkpoint.get("best_val_loss") if best is None: val_hist = checkpoint.get("history", {}).get("val", []) diff --git a/runs/exp07_fourier_hires/xrr_model.py b/runs/exp07_fourier_hires/xrr_model.py index 3f453cb..2f0c290 100644 --- a/runs/exp07_fourier_hires/xrr_model.py +++ b/runs/exp07_fourier_hires/xrr_model.py @@ -6,7 +6,7 @@ class FourierFeatureMapping(nn.Module): """ Random Fourier Features: - 좌표(q)를 고차원 주파수 공간으로 매핑하여 고주파(High-Frequency) 패턴 학습을 돕습니다. + Maps the coordinate (q) to a high-dimensional frequency space to help learn high-frequency patterns. """ def __init__(self, input_dim=1, mapping_size=64, scale=10.0): @@ -38,7 +38,7 @@ def __init__( super().__init__() self.use_fourier = use_fourier inplace: bool = True # False while debugging - # Checkpoint 호환성을 위한 Config 저장 + # store the config for checkpoint compatibility self.config = { "q_len": q_len, "input_channels": input_channels, diff --git a/src/reflecto_exp/io_utils.py b/src/reflecto_exp/io_utils.py index 4cdc9a7..2081643 100644 --- a/src/reflecto_exp/io_utils.py +++ b/src/reflecto_exp/io_utils.py @@ -66,17 +66,16 @@ def load_xrr_dat(file: Path | str) -> tuple[ndarray, ndarray]: - Converts non-numeric garbage to 0.0 safely. """ - # 1. read_csv의 파라미터를 활용해 한 번에 파싱 - # comment='#': #으로 시작하는 줄이나, 데이터 뒤에 붙은 # 주석을 무시함 - # sep=r"\s+": 탭, 공백 등 길이가 다른 공백도 분리자로 인식 + # comment='#' ignores lines starting with # and trailing # comments after the data + # sep=r"\s+" accepts tabs and runs of spaces as separators df = pd.read_csv(file, header=None, sep=r"\s+", comment="#", names=["tth", "R"]) - # 2. 숫자가 아닌 데이터(Garbage)가 섞여 있을 경우를 대비한 안전장치 - # (이미 comment='#'로 대부분 걸러지지만, 혹시 모를 텍스트 찌꺼기 처리) + # guard against non-numeric garbage mixed into the data + # (comment='#' removes most of it, but stray text can remain) df = df.apply(pd.to_numeric, errors="coerce") - # 3. NaN(변환 실패한 값)을 0.0으로 채우고 Numpy 변환 - # 원본 데이터 순서를 보장하기 위해 fillna 사용 + # NaN (failed conversions) becomes 0.0, then convert to numpy + # fillna keeps the original row order df = df.fillna(0.0) return df["tth"].to_numpy(), df["R"].to_numpy() diff --git a/src/reflecto_exp/simulate/simul_genx.py b/src/reflecto_exp/simulate/simul_genx.py index 48421ef..7adfe4f 100644 --- a/src/reflecto_exp/simulate/simul_genx.py +++ b/src/reflecto_exp/simulate/simul_genx.py @@ -24,11 +24,11 @@ def to_numpy(self) -> np.ndarray: @classmethod def from_genx_layer(cls, layer: Layer) -> "ParamSet": """ - GenX Layer 객체를 ParamSet으로 변환합니다. - f (scattering factor)를 SLD (10^-6 A^-2) 단위로 역변환합니다. + Convert a GenX Layer object to a ParamSet. + f (scattering factor) is converted back to SLD in 10^-6 A^-2. Formula: SLD_val = (f.real * dens * r_e) * 1e6 """ - # f는 복소수이므로 실수부(real)만 취함 (전자밀도 관련) + # f is complex; only the real part is taken (electron density) sld_val = (layer.f.real * layer.dens * r_e) * 1e6 return cls(thickness=float(layer.d), roughness=float(layer.sigma), sld=float(sld_val)) @@ -126,7 +126,6 @@ def generate_batch(self) -> Iterator[tuple[list[ParamSet], ParamSet, np.ndarray] """Generator for (Film_Params, SiO2_Params, Reflectivity)""" for _ in range(self.n_samples): - # 1. Main Film film_params: list[ParamSet] = [] for _ in range(self.n_layers): f_d = np.random.uniform(self.t_min, self.t_max) @@ -135,7 +134,6 @@ def generate_batch(self) -> Iterator[tuple[list[ParamSet], ParamSet, np.ndarray] f_sld = np.random.uniform(self.s_min, self.s_max) film_params.append(ParamSet(f_d, f_sig, f_sld)) - # 2. SiO2 s_d = np.random.uniform(self.st_min, self.st_max) s_sig = np.random.uniform(self.sr_min, self.sr_max) s_sld = np.random.uniform(self.ss_min, self.ss_max) @@ -144,7 +142,6 @@ def generate_batch(self) -> Iterator[tuple[list[ParamSet], ParamSet, np.ndarray] sub_sig = np.random.uniform(self.sub_r_min, self.sub_r_max) sub_param = ParamSet.from_genx_layer(SUBSTRATE_SI) sub_param.roughness = sub_sig - # 3. Simulate try: refl = param2refl(self.qs, film_params, sio2_p, sub_param) except RuntimeError as e: diff --git a/src/reflecto_exp/simulate/simul_refnx.py b/src/reflecto_exp/simulate/simul_refnx.py index 2738f73..89cd16f 100644 --- a/src/reflecto_exp/simulate/simul_refnx.py +++ b/src/reflecto_exp/simulate/simul_refnx.py @@ -22,7 +22,7 @@ def to_numpy(self) -> np.ndarray: return np.array([self.thickness, self.roughness, self.sld], dtype=np.float64) def __format__(self, format_spec: str) -> str: - """클래스 전체에 포매팅 명령어 적용 (.3f, .2f 등)""" + """Apply a format spec (.3f, .2f, ...) to the whole class.""" formatted = [f"{name}={getattr(self, name):{format_spec}}" for name in self.__dataclass_fields__.keys()] return f"{self.__class__.__name__}({', '.join(formatted)})" @@ -42,7 +42,6 @@ def tth2q[T: (float, np.ndarray)](tth: T, wavelen: float = 1.54) -> T: def build_structure(params: list[ParamSet]) -> Structure: - """refnx Structure를 연속 파라미터로부터 생성.""" air = SLD(0.0, name="Air") substrate = SLD(20, name="Substrate") @@ -73,7 +72,6 @@ def calc_refl(structure: Structure, q: np.ndarray, wavelen: float = 1.54, is_foo np.ndarray Reflectivity values corrected for beam footprint. """ - # 1. 기본 ReflectModel 계산 model = ReflectModel(structure) R = model(q) if is_footprint: @@ -104,7 +102,7 @@ def __init__( self.n_samples = n_samples self.thick_range = thickness_range # Å - self.rough_range = roughness_range # Å, 주로 0-10Å 세밀 + self.rough_range = roughness_range # A, mostly fine steps within 0-10 A self.sld_range = sld_range # x1e-6 Å^-2 self.max_total_thickness = max_total_thickness @@ -112,25 +110,24 @@ def __init__( self.has_footprint = has_footprint def sample_thicknesses_divide_and_conquer(self) -> np.ndarray: - """Dirichlet 분포를 사용하여 균일한 두께 샘플링""" + """Sample thicknesses uniformly using a Dirichlet distribution.""" min_total = self.n_layers * self.thick_range[0] total_thickness = np.random.uniform(min_total, self.max_total_thickness) proportions = np.random.dirichlet(np.ones(self.n_layers)) thicknesses = total_thickness * proportions - # 안전장치: 범위를 벗어나는 경우 클리핑 thicknesses = np.clip(thicknesses, *self.thick_range) return thicknesses def make_params_refl(self) -> Iterator[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: - """제약 조건을 만족하는 파라미터 생성 (Rejection-Free)""" + """Generate parameters that satisfy the constraints (rejection-free).""" for _ in range(0, self.n_samples): - # 총 두께 제한을 자동으로 만족하는 두께 샘플링 + # thickness sampling that satisfies the total thickness limit by construction thicknesses = self.sample_thicknesses_divide_and_conquer() - # 거칠기 제약: 단일 층 두께의 3% 이하 + # roughness constraint: at most 3% of a single layer thickness rough_min, rough_max = self.rough_range max_roughness_per_layer = np.minimum(thicknesses * 0.03, rough_max) roughnesses = np.random.uniform(rough_min, max_roughness_per_layer, self.n_layers) diff --git a/src/reflecto_exp/simulate/torch_engine/__main__.py b/src/reflecto_exp/simulate/torch_engine/__main__.py index 714c720..ce33d40 100644 --- a/src/reflecto_exp/simulate/torch_engine/__main__.py +++ b/src/reflecto_exp/simulate/torch_engine/__main__.py @@ -11,7 +11,6 @@ roughness = torch.tensor([[3.0, 2.0]]).to(device) sld = torch.tensor([[20.0, 10.0, 0.0]]).to(device) -# 2. 시뮬레이션 실행 R = simulate_reflectivity(q=q, thickness=thickness, roughness=roughness, sld=sld, dq_q=torch.tensor([[0.02]]).to(device)) R_numpy = R.detach().cpu().numpy()