diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..478231b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94e3180 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: Tests +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +jobs: + pytest: + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + MPLBACKEND: Agg + CI: "true" + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.3.0 + with: + enable-cache: true + - name: Run pytest + run: uv run --python 3.13 --group dev pytest -q diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 01cca35..9b1362c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,3 +10,4 @@ jobs: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v10.3.0 - run: uvx ruff@0.17.1 check + - run: uvx ruff@0.17.1 format --check diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml new file mode 100644 index 0000000..9afb430 --- /dev/null +++ b/.github/workflows/readme.yml @@ -0,0 +1,8 @@ +name: readme +on: + push: + branches: [main] + pull_request: +jobs: + readme: + uses: NaNIsaac/.github/.github/workflows/readme.yml@main diff --git a/README.md b/README.md index 27d97c6..44dcceb 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,19 @@ Each folder under `runs/` is one self-contained experiment (config, simulation, ## Install -Uses [uv](https://docs.astral.sh/uv/) and Python 3.13. `torch` is pulled from the PyTorch CUDA 12.8 index. +Uses [uv](https://docs.astral.sh/uv/) and Python 3.13. `torch` is pulled from the PyTorch CUDA 12.8 index (PyPI on macOS). ```bash git clone https://github.com/NaNIsaac/ReflectoExp.git cd ReflectoExp +``` + +Install the dependencies and the `reflecto_exp` package: + + +```bash uv sync -uv pip install -e . # installs the reflecto_exp package +uv pip install -e . ``` The refnx-based experiments (exp02, exp03) also need `uv pip install refnx`, which is not a declared dependency. diff --git a/main.py b/main.py index 2298ffe..d55df8f 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,7 @@ def get_venv_python() -> Path: return venv_python + def find_exp_folder(exp_num: int, runs_dir_path: Path = Path("./runs")) -> Path: """ runs 폴더 내에서 exp{num} 또는 exp0{num}으로 시작하는 폴더를 찾습니다. @@ -52,6 +53,7 @@ def find_exp_folder(exp_num: int, runs_dir_path: Path = Path("./runs")) -> Path: return found_folder + def run_main(target_folder: Path): """ 찾은 폴더 내부의 main.py를 가상환경 Python으로 실행합니다. @@ -72,15 +74,16 @@ def run_main(target_folder: Path): subprocess.run( [str(venv_python), "main.py"], cwd=target_folder, # 작업 디렉토리를 해당 실험 폴더로 변경 - check=True + check=True, ) except subprocess.CalledProcessError as e: print("-" * 50) print(f"💥 [Fail] 실행 중 오류 발생 (Exit code: {e.returncode})") + if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Run experiment based on ID') - parser.add_argument('exp_num', type=int, help='Experiment number (e.g., 5)') + parser = argparse.ArgumentParser(description="Run experiment based on ID") + parser.add_argument("exp_num", type=int, help="Experiment number (e.g., 5)") args = parser.parse_args() diff --git a/notebooks/function_test.ipynb b/notebooks/function_test.ipynb index 8ec870b..f3e6f0e 100644 --- a/notebooks/function_test.ipynb +++ b/notebooks/function_test.ipynb @@ -15,7 +15,7 @@ "from reflecto.simulate.simulator import ParamSet, build_structure, compute_refl\n", "\n", "wavelen: float = 1.54 # (nm)\n", - "tth_min: float = 0.1 # degree\n", + "tth_min: float = 0.1 # degree\n", "tth_max: float = 6.0\n", "q_n: int = 200\n", "tths: np.ndarray = np.linspace(tth_min, tth_max, q_n)\n", @@ -54,7 +54,7 @@ "fig, ax = plt.subplots(1, 1, figsize=(8, 5))\n", "ax.plot(qs, refl)\n", "ax.plot(qs, refl2)\n", - "ax.set_yscale('log')\n", + "ax.set_yscale(\"log\")\n", "\n", "plt.show()" ] @@ -84,7 +84,7 @@ "slds = [f.sld for f in films]\n", "fig, ax = plt.subplots(1, 1, figsize=(8, 5))\n", "ax.plot(qs, refl)\n", - "ax.set_yscale('log')" + "ax.set_yscale(\"log\")" ] }, { @@ -101,8 +101,8 @@ "# Manual: 평균 30분 (1800초) ~ 60분 범위\n", "# Hybrid: 평균 2초 ~ 4초 범위\n", "means = [1800, 3]\n", - "labels = ['Manual Fitting\\n(Expert)', 'Hybrid Pipeline\\n(Ours)']\n", - "colors = ['#bdbdbd', '#2e7d32'] # 회색 vs 초록색\n", + "labels = [\"Manual Fitting\\n(Expert)\", \"Hybrid Pipeline\\n(Ours)\"]\n", + "colors = [\"#bdbdbd\", \"#2e7d32\"] # 회색 vs 초록색\n", "\n", "fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(5, 6))\n", "fig.subplots_adjust(hspace=0.1) # 위아래 그래프 간격 조절\n", @@ -113,19 +113,19 @@ "\n", "# 축 범위 설정 (Broken Axis 효과)\n", "ax1.set_ylim(1500, 2000) # 위쪽 그래프: 1500~2000초 구간\n", - "ax2.set_ylim(0, 100) # 아래쪽 그래프: 0~100초 구간\n", + "ax2.set_ylim(0, 100) # 아래쪽 그래프: 0~100초 구간\n", "\n", "# 위쪽 그래프의 X축 눈금 제거\n", - "ax1.spines['bottom'].set_visible(False)\n", - "ax2.spines['top'].set_visible(False)\n", + "ax1.spines[\"bottom\"].set_visible(False)\n", + "ax2.spines[\"top\"].set_visible(False)\n", "ax1.xaxis.tick_top()\n", "ax1.tick_params(labeltop=False) # 위쪽 라벨 제거\n", "ax2.xaxis.tick_bottom()\n", "\n", "# 빗금(Diagonal lines)으로 잘린 부분 표현\n", - "d = .015\n", - "kwargs = {'transform': ax1.transAxes, 'color': 'k', 'clip_on': False}\n", - "ax1.plot((-d, +d), (-d, +d), **kwargs) # top-left diagonal\n", + "d = 0.015\n", + "kwargs = {\"transform\": ax1.transAxes, \"color\": \"k\", \"clip_on\": False}\n", + "ax1.plot((-d, +d), (-d, +d), **kwargs) # top-left diagonal\n", "ax1.plot((1 - d, 1 + d), (-d, +d), **kwargs) # top-right diagonal\n", "\n", "kwargs.update(transform=ax2.transAxes) # switch to the bottom axes\n", @@ -133,12 +133,12 @@ "ax2.plot((1 - d, 1 + d), (1 - d, 1 + d), **kwargs) # bottom-right diagonal\n", "\n", "# 텍스트 라벨 추가 (가장 중요!)\n", - "ax1.text(0, 1850, \"~ 30 mins\", ha='center', va='bottom', fontsize=14, fontweight='bold')\n", - "ax2.text(1, 10, \"< 3 sec\", ha='center', va='bottom', fontsize=14, fontweight='bold', color='#2e7d32')\n", + "ax1.text(0, 1850, \"~ 30 mins\", ha=\"center\", va=\"bottom\", fontsize=14, fontweight=\"bold\")\n", + "ax2.text(1, 10, \"< 3 sec\", ha=\"center\", va=\"bottom\", fontsize=14, fontweight=\"bold\", color=\"#2e7d32\")\n", "\n", "# 제목 및 Y축 라벨\n", - "ax1.set_title(\"Processing Time per Scan\", fontsize=14, fontweight='bold', pad=20)\n", - "fig.text(0.02, 0.5, 'Time (seconds)', va='center', rotation='vertical', fontsize=12)\n", + "ax1.set_title(\"Processing Time per Scan\", fontsize=14, fontweight=\"bold\", pad=20)\n", + "fig.text(0.02, 0.5, \"Time (seconds)\", va=\"center\", rotation=\"vertical\", fontsize=12)\n", "\n", "plt.show()" ] diff --git a/pyproject.toml b/pyproject.toml index da1ef38..ae0d29d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,18 @@ url = "https://download.pytorch.org/whl/cu128" explicit = true [tool.uv.sources] -torch = [{ index = "pytorch-cu128" }] -torchvision = [{ index = "pytorch-cu128" }] -torchaudio = [{ index = "pytorch-cu128" }] +torch = [{ index = "pytorch-cu128", marker = "sys_platform != 'darwin'" }] +torchvision = [{ index = "pytorch-cu128", marker = "sys_platform != 'darwin'" }] +torchaudio = [{ index = "pytorch-cu128", marker = "sys_platform != 'darwin'" }] + +# GenX lists wxpython for its GUI, which this project never starts. PyPI has no Linux wheel for it, +# so on Linux uv would compile it and fail without GTK headers. +[tool.uv] +override-dependencies = ["wxpython; sys_platform != 'linux'"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] [tool.ruff] line-length = 150 @@ -110,5 +119,6 @@ combine-as-imports = true [dependency-groups] dev = [ + "pytest>=9.0.2", "ruff>=0.14.3", ] \ No newline at end of file diff --git a/runs/exp01_quan_class/dataset.py b/runs/exp01_quan_class/dataset.py index a3bc332..e6ed865 100644 --- a/runs/exp01_quan_class/dataset.py +++ b/runs/exp01_quan_class/dataset.py @@ -28,12 +28,7 @@ class ParamQuantizer: q.quantize(param) -> np.ndarray([t_idx, r_idx, s_idx]) """ - def __init__( - self, - thickness_bins: np.ndarray | None = None, - roughness_bins: np.ndarray | None = None, - sld_bins: np.ndarray | None = None - ): + def __init__(self, thickness_bins: np.ndarray | None = None, roughness_bins: np.ndarray | None = None, sld_bins: np.ndarray | None = None): # sensible defaults (units: nm for thickness, Å for roughness, 1e-6 Å^-2 or user convention) self.thickness_bins = np.linspace(0.0, 200.0, 51) if thickness_bins is None else np.asarray(thickness_bins) self.roughness_bins = np.linspace(0.0, 10.0, 21) if roughness_bins is None else np.asarray(roughness_bins) @@ -103,11 +98,14 @@ def quantize(self, param: ParamSet) -> np.ndarray: t = float(param.thickness) r = float(param.roughness) s = float(param.sld) - return np.array([ - self._quantize_array(np.asarray([t]), self.thickness_bins)[0], - self._quantize_array(np.asarray([r]), self.roughness_bins)[0], - self._quantize_array(np.asarray([s]), self.sld_bins)[0], - ], dtype=np.int64) + return np.array( + [ + self._quantize_array(np.asarray([t]), self.thickness_bins)[0], + self._quantize_array(np.asarray([r]), self.roughness_bins)[0], + self._quantize_array(np.asarray([s]), self.sld_bins)[0], + ], + dtype=np.int64, + ) def quantize_multi(self, thickness_arr: np.ndarray, roughness_arr: np.ndarray, sld_arr: np.ndarray) -> np.ndarray: """Vectorized quantize for arrays per-layer. Returns shape (n_layers, 3).""" @@ -214,7 +212,7 @@ def __getitem__(self, idx): self._ensure_open() hf = self._hf - refl = hf["R"][idx] # (q_len,) or (q_len, ...) + refl = hf["R"][idx] # (q_len,) or (q_len, ...) thick = hf["thickness"][idx] # (n_layers,) rough = hf["roughness"][idx] sld = hf["sld"][idx] diff --git a/runs/exp01_quan_class/evaluate.py b/runs/exp01_quan_class/evaluate.py index 7a69074..352e65a 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, # Windows에서는 0~2 권장 pin_memory: bool = True, ): self.h5_path = Path(h5_path) @@ -39,11 +39,7 @@ def __init__( # Dataset self.val_dataset = DatasetH5(self.h5_path, self.quantizer) self.val_loader = DataLoader( - self.val_dataset, - batch_size=self.batch_size, - shuffle=False, - num_workers=self.num_workers, - pin_memory=self.pin_memory + self.val_dataset, batch_size=self.batch_size, shuffle=False, num_workers=self.num_workers, pin_memory=self.pin_memory ) # Model @@ -51,13 +47,9 @@ def __init__( self.q_len = sample_refl.shape[0] self.n_layers = sample_label.shape[0] - 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) + 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: @@ -108,12 +100,9 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): # --- 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)) + - self.ce(sld_logits.reshape(-1, sld_logits.shape[-1]), - tgt_sld.reshape(-1)) + 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)) + + self.ce(sld_logits.reshape(-1, sld_logits.shape[-1]), tgt_sld.reshape(-1)) ) all_losses.append(loss.item()) @@ -145,12 +134,7 @@ def evaluate(self, use_amp: bool = False, logits_clip: float = 50.0): self.acc_sld = (self.all_pred_sld == self.all_tgt_sld).mean() self.avg_loss = np.mean(all_losses) - return { - "loss": self.avg_loss, - "acc_th": self.acc_th, - "acc_rg": self.acc_rg, - "acc_sld": self.acc_sld - } + return {"loss": self.avg_loss, "acc_th": self.acc_th, "acc_rg": self.acc_rg, "acc_sld": self.acc_sld} def plot_loss(self, window: int = 50): losses = np.array(self.all_losses) @@ -161,8 +145,8 @@ def plot_loss(self, window: int = 50): # Moving average if len(losses) >= window: - ma = np.convolve(losses, np.ones(window)/window, mode="valid") - plt.plot(range(window-1, window-1+len(ma)), ma, linewidth=2, label=f"MA({window})") + ma = np.convolve(losses, np.ones(window) / window, mode="valid") + plt.plot(range(window - 1, window - 1 + len(ma)), ma, linewidth=2, label=f"MA({window})") plt.xlabel("Batch") plt.ylabel("Loss") @@ -174,12 +158,15 @@ def plot_loss(self, window: int = 50): def plot_error_histogram(self): plt.figure(figsize=(15, 4)) - for i, (tgt, pred, name) in enumerate(zip( - [self.all_tgt_th, self.all_tgt_rg, self.all_tgt_sld], - [self.all_pred_th, self.all_pred_rg, self.all_pred_sld], - ["Thickness", "Roughness", "SLD"], strict=False - )): - plt.subplot(1, 3, i+1) + for i, (tgt, pred, name) in enumerate( + zip( + [self.all_tgt_th, self.all_tgt_rg, self.all_tgt_sld], + [self.all_pred_th, self.all_pred_rg, self.all_pred_sld], + ["Thickness", "Roughness", "SLD"], + strict=False, + ) + ): + plt.subplot(1, 3, i + 1) errors = pred - tgt sns.histplot(errors, bins=40, kde=True) @@ -187,8 +174,8 @@ def plot_error_histogram(self): std = errors.std() plt.axvline(mean, color="r", linestyle="--", label=f"Mean = {mean:.2f}") - plt.axvline(mean+std, color="g", linestyle=":", label=f"+1σ = {mean+std:.2f}") - plt.axvline(mean-std, color="g", linestyle=":", label=f"-1σ = {mean-std:.2f}") + plt.axvline(mean + std, color="g", linestyle=":", label=f"+1σ = {mean + std:.2f}") + plt.axvline(mean - std, color="g", linestyle=":", label=f"-1σ = {mean - std:.2f}") plt.title(f"{name} Error Distribution") plt.xlabel("Prediction - Target") @@ -200,12 +187,15 @@ def plot_error_histogram(self): def plot_scatter(self, use_hexbin: bool = False): plt.figure(figsize=(15, 4)) - for i, (tgt, pred, name) in enumerate(zip( - [self.all_tgt_th, self.all_tgt_rg, self.all_tgt_sld], - [self.all_pred_th, self.all_pred_rg, self.all_pred_sld], - ["Thickness", "Roughness", "SLD"], strict=False - )): - plt.subplot(1, 3, i+1) + for i, (tgt, pred, name) in enumerate( + zip( + [self.all_tgt_th, self.all_tgt_rg, self.all_tgt_sld], + [self.all_pred_th, self.all_pred_rg, self.all_pred_sld], + ["Thickness", "Roughness", "SLD"], + strict=False, + ) + ): + plt.subplot(1, 3, i + 1) if use_hexbin: plt.hexbin(tgt, pred, gridsize=40, cmap="viridis", mincnt=1) @@ -233,17 +223,9 @@ def plot_scatter(self, use_hexbin: bool = False): def plot_correlation(self): import pandas as pd - df_target = pd.DataFrame({ - "Thickness": self.all_tgt_th, - "Roughness": self.all_tgt_rg, - "SLD": self.all_tgt_sld - }) + df_target = pd.DataFrame({"Thickness": self.all_tgt_th, "Roughness": self.all_tgt_rg, "SLD": self.all_tgt_sld}) - df_pred = pd.DataFrame({ - "Thickness": self.all_pred_th, - "Roughness": self.all_pred_rg, - "SLD": self.all_pred_sld - }) + df_pred = pd.DataFrame({"Thickness": self.all_pred_th, "Roughness": self.all_pred_rg, "SLD": self.all_pred_sld}) df_error = df_pred - df_target @@ -262,13 +244,12 @@ def plot_correlation(self): plt.show() - if __name__ == "__main__": evaluator = XRREvaluator( 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, # 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 58a1a58..d116bc3 100644 --- a/runs/exp01_quan_class/model.py +++ b/runs/exp01_quan_class/model.py @@ -18,7 +18,7 @@ def __init__(self, in_channels: int = 1, base_channels: int = 32, n_layers: int layers = [] ch = in_channels for i in range(n_layers): - out_ch = base_channels * (2 ** i) if i < 3 else base_channels * (2 ** 3) + out_ch = base_channels * (2**i) if i < 3 else base_channels * (2**3) layers.append(nn.Conv1d(ch, out_ch, kernel_size=kernel_size, padding=kernel_size // 2)) layers.append(nn.BatchNorm1d(out_ch)) layers.append(nn.ReLU(inplace=True)) @@ -118,10 +118,12 @@ def forward(self, x): # Loss helper # ------------------------- def multitask_loss( - logits_th, logits_rg, logits_sld, + logits_th, + logits_rg, + logits_sld, targets, # (B, L, 3) weights=(1.0, 1.0, 1.0), - ignore_index: int = -100 + ignore_index: int = -100, ) -> tuple[torch.Tensor, tuple[float, float, float]]: B, L, _ = targets.shape device = logits_th.device @@ -152,9 +154,14 @@ def accuracy_from_logits(logits, targets): # ------------------------- # Training / Validation # ------------------------- -def train_epoch(model: nn.Module, loader: torch.utils.data.DataLoader, optimizer: torch.optim.Optimizer, - device: torch.device, scaler: torch.cuda.amp.GradScaler = None, - clip_grad: float = 1.0) -> float: +def train_epoch( + model: nn.Module, + loader: torch.utils.data.DataLoader, + optimizer: torch.optim.Optimizer, + device: torch.device, + scaler: torch.cuda.amp.GradScaler = None, + clip_grad: float = 1.0, +) -> float: """ Single epoch training with optional AMP scaler. Returns: (avg_loss, {"th":.., "rg":.., "sld":..}) @@ -171,7 +178,7 @@ def train_epoch(model: nn.Module, loader: torch.utils.data.DataLoader, optimizer optimizer.zero_grad() if scaler is not None: - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): logits_th, logits_rg, logits_sld = model(refl) loss_th = ce(logits_th.view(-1, logits_th.shape[-1]), labels[:, :, 0].view(-1)) loss_rg = ce(logits_rg.view(-1, logits_rg.shape[-1]), labels[:, :, 1].view(-1)) @@ -205,10 +212,7 @@ def train_epoch(model: nn.Module, loader: torch.utils.data.DataLoader, optimizer def validate_epoch( - model: nn.Module, - dataloader: torch.utils.data.DataLoader, - device: torch.device, - logits_clip: float = 50.0 + model: nn.Module, dataloader: torch.utils.data.DataLoader, device: torch.device, logits_clip: float = 50.0 ) -> tuple[float, dict[str, float]]: model.eval() @@ -231,9 +235,7 @@ def validate_epoch( logits_rg = logits_rg.clamp(-logits_clip, logits_clip) logits_sld = logits_sld.clamp(-logits_clip, logits_clip) - if not (torch.isfinite(logits_th).all() and - torch.isfinite(logits_rg).all() and - torch.isfinite(logits_sld).all()): + if not (torch.isfinite(logits_th).all() and torch.isfinite(logits_rg).all() and torch.isfinite(logits_sld).all()): raise RuntimeError("Non-finite logits encountered in validation.") loss, _ = multitask_loss(logits_th, logits_rg, logits_sld, labels) diff --git a/runs/exp01_quan_class/train.py b/runs/exp01_quan_class/train.py index 4aaabc6..a25b7a5 100644 --- a/runs/exp01_quan_class/train.py +++ b/runs/exp01_quan_class/train.py @@ -43,10 +43,8 @@ def __init__( self.train_dataset = DatasetH5(self.h5_path, self.quantizer, device="cpu") self.val_dataset = DatasetH5(self.h5_path, self.quantizer, device="cpu") - self.train_loader = DataLoader(self.train_dataset, batch_size=self.batch_size, shuffle=True, - num_workers=4, pin_memory=True) - self.val_loader = DataLoader(self.val_dataset, batch_size=self.batch_size, shuffle=False, - num_workers=4, pin_memory=True) + self.train_loader = DataLoader(self.train_dataset, batch_size=self.batch_size, shuffle=True, num_workers=4, pin_memory=True) + self.val_loader = DataLoader(self.val_dataset, batch_size=self.batch_size, shuffle=False, num_workers=4, pin_memory=True) # Model sample_refl, sample_label = self.train_dataset[0] @@ -54,13 +52,9 @@ def __init__( self.n_layers = sample_label.shape[0] print(f"Detected q_len: {self.q_len}, n_layers: {self.n_layers}") - 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) + 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 + ) self.optimizer = AdamW(self.model.parameters(), lr=self.lr, weight_decay=self.weight_decay) self.scheduler = StepLR(self.optimizer, step_size=scheduler_step, gamma=scheduler_gamma) diff --git a/runs/exp02_one_layer/dataset.py b/runs/exp02_one_layer/dataset.py index 5c4d4a5..d2de940 100644 --- a/runs/exp02_one_layer/dataset.py +++ b/runs/exp02_one_layer/dataset.py @@ -50,22 +50,21 @@ def _setup_normalization(self): self.refl_mean = np.mean(R_log, axis=0) self.refl_std = np.std(R_log, axis=0) - params = np.stack([ - self.thickness[train_indices], - self.roughness[train_indices], - self.sld[train_indices] - ], axis=1) + 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) self.param_std = np.std(params, axis=0) # torch tensor로 저장하여 torch.load 문제 해결 - torch.save({ - "refl_mean": torch.from_numpy(self.refl_mean), - "refl_std": torch.from_numpy(self.refl_std), - "param_mean": torch.from_numpy(self.param_mean), - "param_std": torch.from_numpy(self.param_std), - }, self.stats_path) + torch.save( + { + "refl_mean": torch.from_numpy(self.refl_mean), + "refl_std": torch.from_numpy(self.refl_std), + "param_mean": torch.from_numpy(self.param_mean), + "param_std": torch.from_numpy(self.param_std), + }, + self.stats_path, + ) print(f"정규화 파라미터 저장: {self.stats_path}") else: @@ -88,18 +87,12 @@ def __getitem__(self, idx): 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 = 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 - return ( - torch.from_numpy(R_norm).float(), - torch.from_numpy(params_norm).float() - ) + 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: """모델 출력(정규화된)을 실제 물리 파라미터 스케일로 복원.""" if isinstance(params_norm, torch.Tensor): @@ -107,6 +100,7 @@ def denormalize_params(self, params_norm: torch.Tensor | np.ndarray) -> np.ndarr params = params_norm * self.param_std + self.param_mean return params + def normalize_reflectivity(self, R_real: np.ndarray) -> torch.Tensor: """실험 R 데이터를 모델 입력용으로 정규화.""" R_log = np.log10(np.maximum(R_real, 1e-15)) diff --git a/runs/exp02_one_layer/evaluate.py b/runs/exp02_one_layer/evaluate.py index 37dccf9..ef4564d 100644 --- a/runs/exp02_one_layer/evaluate.py +++ b/runs/exp02_one_layer/evaluate.py @@ -39,15 +39,15 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file # 오류 계산 errors = preds - targets mae = torch.mean(torch.abs(errors), dim=0) - rmse = torch.sqrt(torch.mean(errors ** 2, dim=0)) + rmse = torch.sqrt(torch.mean(errors**2, dim=0)) - print("\n" + "="*50) + print("\n" + "=" * 50) print("TEST RESULTS") - print("="*50) + print("=" * 50) param_names = ["Thickness (Å)", "Roughness (Å)", "SLD (10⁻⁶ Å⁻²)"] for i, name in enumerate(param_names): print(f"{name:15s}: MAE={mae[i]:7.3f} | RMSE={rmse[i]:7.3f}") - print("="*50) + print("=" * 50) # 시각화 if save_plot: @@ -57,18 +57,18 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file ax = axes[i] data = errors[:, i].numpy() - ax.hist(data, bins=50, density=True, alpha=0.6, color='steelblue', edgecolor='black') - ax.axvline(0, color='red', linestyle='--', linewidth=2) - ax.axvline(np.mean(data), color='orange', linestyle=':', label=f'Mean: {np.mean(data):.3f}') + ax.hist(data, bins=50, density=True, alpha=0.6, color="steelblue", edgecolor="black") + ax.axvline(0, color="red", linestyle="--", linewidth=2) + ax.axvline(np.mean(data), color="orange", linestyle=":", label=f"Mean: {np.mean(data):.3f}") - ax.set_xlabel('Prediction Error') - ax.set_ylabel('Density') - ax.set_title(f'{name}\nMAE={mae[i]:.3f}') + ax.set_xlabel("Prediction Error") + ax.set_ylabel("Density") + ax.set_title(f"{name}\nMAE={mae[i]:.3f}") ax.legend() ax.grid(alpha=0.3) plt.tight_layout() - plt.savefig(report_file, dpi=300, bbox_inches='tight') + plt.savefig(report_file, dpi=300, bbox_inches="tight") print(f"오류 분포 그래프 저장: {report_file}") return errors, mae, rmse @@ -77,13 +77,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'] + 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')) + 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) @@ -101,7 +101,6 @@ def load_checkpoint_and_evaluate(test_loader: DataLoader, checkpoint_path: Path, report_file = exp_dir / "error_distribution.png" test_set = XRR1LayerDataset(h5_file, stats_file, mode="test", val_ratio=CONFIG["training"]["val_ratio"]) - test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) + test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) load_checkpoint_and_evaluate(test_loader, checkpoint_file, stats_file, 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 498573d..809b885 100644 --- a/runs/exp02_one_layer/evaluate_with_real_data.py +++ b/runs/exp02_one_layer/evaluate_with_real_data.py @@ -26,6 +26,7 @@ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarra return out[0] return out + # ------------------------------ # Helper: stats 불러와 파라미터 정규화/역정규화 # ------------------------------ @@ -35,18 +36,21 @@ def load_stats(stats_path: Path) -> tuple[np.ndarray, np.ndarray]: param_std = st["param_std"].numpy() if isinstance(st["param_std"], torch.Tensor) else np.array(st["param_std"]) return param_mean, param_std + def normalize_params(params: np.ndarray, param_mean: np.ndarray, param_std: np.ndarray) -> np.ndarray: return (params - param_mean) / param_std + def denormalize_params(params_norm: np.ndarray, param_mean: np.ndarray, param_std: np.ndarray) -> np.ndarray: 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,) + measured_I: np.ndarray, # shape (N, M) or (M,) true_params: np.ndarray | None, # shape (N, n_params) or None target_q: np.ndarray, stats_path: Path, @@ -82,11 +86,12 @@ def make_dataloader_from_arrays( loader = DataLoader(dataset, batch_size=batch_size, shuffle=False) 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')) + device = device or (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")) model = model.to(device) model.eval() @@ -115,13 +120,13 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr errors = preds - targets mae = np.mean(np.abs(errors), axis=0) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) n_params = preds.shape[1] param_names = ["Thickness (nm)", "Roughness (Å)", "SLD"][:n_params] # Print summary - print("\n" + "="*40) + print("\n" + "=" * 40) print("EVALUATION SUMMARY") for i, name in enumerate(param_names): print(f"{name:15s} MAE={mae[i]:.4f} | RMSE={rmse[i]:.4f}") @@ -132,14 +137,14 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # parity plots for i, name in enumerate(param_names): - plt.figure(figsize=(5,5)) + plt.figure(figsize=(5, 5)) plt.scatter(targets[:, i], preds[:, i], s=10) mn = min(targets[:, i].min(), preds[:, i].min()) mx = max(targets[:, i].max(), preds[:, i].max()) - plt.plot([mn, mx], [mn, mx], 'r--') - plt.xlabel('True') - plt.ylabel('Predicted') - plt.title(f'Parity: {name}') + plt.plot([mn, mx], [mn, mx], "r--") + plt.xlabel("True") + plt.ylabel("Predicted") + plt.title(f"Parity: {name}") plt.grid(alpha=0.3) plt.tight_layout() # pfile = out_prefix / f'parity_{i}_{name.replace(" ", "_")}.png' @@ -149,12 +154,12 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # residual histograms for i, name in enumerate(param_names): - plt.figure(figsize=(6,4)) + plt.figure(figsize=(6, 4)) plt.hist(errors[:, i], bins=50, density=True, alpha=0.7) - plt.axvline(0, color='k', linestyle='--') - plt.title(f'Residual histogram: {name} (MAE={mae[i]:.4f})') - plt.xlabel('Error') - plt.ylabel('Density') + plt.axvline(0, color="k", linestyle="--") + plt.title(f"Residual histogram: {name} (MAE={mae[i]:.4f})") + plt.xlabel("Error") + plt.ylabel("Density") plt.tight_layout() # pfile = out_prefix / f'resid_hist_{i}_{name.replace(" ", "_")}.png' # plt.savefig(pfile, dpi=300) @@ -164,20 +169,22 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # Optional: save per-sample spectrum comparison (measured vs reconstructed simulated from preds) # If you have forward simulator, you can reconstruct reflectivity from predicted params and compare. # Here we only save raw input R for visual inspection - np.save(out_prefix / 'preds.npy', preds) - np.save(out_prefix / 'targets.npy', targets) - np.save(out_prefix / 'Rs.npy', Rs.numpy()) + np.save(out_prefix / "preds.npy", preds) + np.save(out_prefix / "targets.npy", targets) + np.save(out_prefix / "Rs.npy", Rs.numpy()) print(f"Saved diagnostics to {out_prefix.resolve()}") return preds, targets, errors + # ------------------------------ # Example usage block (사용자 환경에 맞게 수정) # ------------------------------ if __name__ == "__main__": from config import CONFIG from model import XRR1DRegressor + # ----------------------------- # 경로 설정 # ----------------------------- @@ -188,9 +195,9 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr 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,) + 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,) try: measured_params = np.load(real_data_dir / "measured_params.npy") # (N, 3) except FileNotFoundError: @@ -199,20 +206,20 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # ----------------------------- # 모델 불러오기 # ----------------------------- - ckpt = torch.load(checkpoint_file, map_location='cpu') - config = ckpt['config']['model_args'] + ckpt = torch.load(checkpoint_file, 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')) + 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 - tth_max = 6.0 # deg + tth_min = 1.0 # deg + tth_max = 6.0 # deg # θ = tth/2 tth_deg = np.linspace(tth_min, tth_max, q_points) diff --git a/runs/exp02_one_layer/main.py b/runs/exp02_one_layer/main.py index 0111d5c..1954291 100644 --- a/runs/exp02_one_layer/main.py +++ b/runs/exp02_one_layer/main.py @@ -11,6 +11,7 @@ np.random.seed(42) # ========================================================================= + def main(): print("1-Layer XRR Regression 파이프라인 시작") exp_dir = CONFIG["base_dir"] / CONFIG["exp_name"] @@ -25,18 +26,16 @@ def main(): print("데이터 파일 없음, 시뮬레이션 실행...") h5_file.parent.mkdir(exist_ok=True) import simulate + simulate.generate_1layer_data(CONFIG, h5_file) train_set = XRR1LayerDataset(h5_file, stats_file, mode="train", val_ratio=CONFIG["training"]["val_ratio"]) val_set = XRR1LayerDataset(h5_file, stats_file, mode="val", val_ratio=CONFIG["training"]["val_ratio"]) test_set = XRR1LayerDataset(h5_file, stats_file, mode="test", val_ratio=CONFIG["training"]["val_ratio"]) - train_loader = DataLoader(train_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=True, num_workers=0, pin_memory=True) - val_loader = DataLoader(val_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) - test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) + train_loader = DataLoader(train_set, batch_size=CONFIG["training"]["batch_size"], shuffle=True, num_workers=0, pin_memory=True) + val_loader = DataLoader(val_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) + test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) print(f"데이터셋: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") @@ -69,11 +68,12 @@ def main(): trainer.train(CONFIG["training"]["epochs"]) # 최종 평가 - print("\n" + "="*50) + print("\n" + "=" * 50) print("최종 테스트 평가") - print("="*50) + print("=" * 50) load_checkpoint_and_evaluate(test_loader, checkpoint_file, stats_file, report_file) + if __name__ == "__main__": main() diff --git a/runs/exp02_one_layer/make_one_fake.py b/runs/exp02_one_layer/make_one_fake.py index 0e3c089..8de6976 100644 --- a/runs/exp02_one_layer/make_one_fake.py +++ b/runs/exp02_one_layer/make_one_fake.py @@ -9,7 +9,6 @@ def generate_dummy_xrr(qs, param: ParamSet): - n_layers: int = 2 n_samples: int = 1_000_000 sim: XRRSimulator = XRRSimulator(qs, n_layers, n_samples) @@ -18,11 +17,12 @@ def generate_dummy_xrr(qs, param: ParamSet): return R -if __name__ == '__main__': +if __name__ == "__main__": from pathlib import Path - # q-space 생성 (당신 config와 동일) + + # q-space 생성 (당신 config와 동일) wavelen: float = 1.54 # (nm) - tth_min: float = 1.0 # degree + tth_min: float = 1.0 # degree tth_max: float = 6.0 q_min: float = tth2q(tth_min, wavelen) # (1/Å) q_max: float = tth2q(tth_max, wavelen) diff --git a/runs/exp02_one_layer/model.py b/runs/exp02_one_layer/model.py index 6fcc361..b986b50 100644 --- a/runs/exp02_one_layer/model.py +++ b/runs/exp02_one_layer/model.py @@ -3,15 +3,14 @@ class XRR1DRegressor(nn.Module): - def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, - mlp_hidden: int = 256, dropout: float = 0.1): + def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: int = 256, dropout: float = 0.1): super().__init__() self.config = { - 'q_len': q_len, - 'n_channels': n_channels, - 'depth': depth, - 'mlp_hidden': mlp_hidden, - 'dropout': dropout, + "q_len": q_len, + "n_channels": n_channels, + "depth": depth, + "mlp_hidden": mlp_hidden, + "dropout": dropout, } # 1D CNN Encoder @@ -19,13 +18,15 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, in_ch = 1 for i in range(depth): out_ch = n_channels * (2 ** min(i, 3)) # 64, 128, 256, 512 - layers.extend([ - nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), - nn.BatchNorm1d(out_ch), - nn.ReLU(inplace=True), - nn.Dropout1d(dropout), - nn.MaxPool1d(2) - ]) + layers.extend( + [ + nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), + nn.BatchNorm1d(out_ch), + nn.ReLU(inplace=True), + nn.Dropout1d(dropout), + nn.MaxPool1d(2), + ] + ) in_ch = out_ch self.encoder = nn.Sequential(*layers) @@ -41,7 +42,7 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, 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), # 3개 파라미터 출력 ) self._init_weights() @@ -50,7 +51,7 @@ 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') + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) diff --git a/runs/exp02_one_layer/simulate.py b/runs/exp02_one_layer/simulate.py index 20e944a..fc5a725 100644 --- a/runs/exp02_one_layer/simulate.py +++ b/runs/exp02_one_layer/simulate.py @@ -36,10 +36,9 @@ def generate_1layer_data(config: dict, h5_file: Path | str): thickness_range=param_ranges["thickness"], roughness_range=param_ranges["roughness"], sld_range=param_ranges["sld"], - has_footprint=True + has_footprint=True, ) - simulator.save_hdf5(h5_file, show_progress=True) print(f"\n 데이터 저장 완료: {h5_file}") @@ -47,5 +46,6 @@ def generate_1layer_data(config: dict, h5_file: Path | str): print(f" - q 포인트: {len(q_values)}") print(f" - 파라미터 범위: {param_ranges}") + if __name__ == "__main__": pass diff --git a/runs/exp02_one_layer/train.py b/runs/exp02_one_layer/train.py index 0792289..a94d84f 100644 --- a/runs/exp02_one_layer/train.py +++ b/runs/exp02_one_layer/train.py @@ -9,15 +9,13 @@ class Trainer: - def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, - save_dir: str | Path, - lr: float = 1e-3, weight_decay: float = 1e-5): + def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, lr: float = 1e-3, weight_decay: float = 1e-5): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = model.to(self.device) self.optimizer = optim.AdamW(self.model.parameters(), lr=lr, weight_decay=weight_decay) - self.scheduler = ReduceLROnPlateau(self.optimizer, mode='min', patience=7, factor=0.5) + self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=7, factor=0.5) self.train_loader = train_loader self.val_loader = val_loader @@ -25,7 +23,7 @@ def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, self.checkpoint_dir = Path(save_dir) self.checkpoint_dir.mkdir(exist_ok=True) - self.best_val_loss = float('inf') + self.best_val_loss = float("inf") self.patience_counter = 0 self.early_stop_patience = 15 @@ -48,7 +46,7 @@ def train_epoch(self, epoch: int) -> float: self.optimizer.step() losses.append(loss.item()) - pbar.set_postfix({'loss': f'{loss.item():.4f}'}) + pbar.set_postfix({"loss": f"{loss.item():.4f}"}) return np.mean(losses) @@ -60,7 +58,7 @@ def validate(self) -> float: for R, params in self.val_loader: R, params = R.to(self.device), params.to(self.device) pred = self.model(R) - total_loss += torch.nn.functional.mse_loss(pred, params, reduction='sum').item() + total_loss += torch.nn.functional.mse_loss(pred, params, reduction="sum").item() return total_loss / len(self.val_loader.dataset) @@ -88,8 +86,7 @@ def train(self, epochs: int, resume_from: str | Path | None = None): train_loss = self.train_epoch(epoch) val_loss = self.validate() - print(f"【Epoch {epoch:02d}】 Train Loss: {train_loss:.4f} | " - f"Val Loss: {val_loss:.4f} | LR: {self.optimizer.param_groups[0]['lr']:.6f}") + print(f"【Epoch {epoch:02d}】 Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | LR: {self.optimizer.param_groups[0]['lr']:.6f}") self.scheduler.step(val_loss) @@ -97,7 +94,7 @@ def train(self, epochs: int, resume_from: str | Path | None = None): if val_loss < self.best_val_loss: self.best_val_loss = val_loss self.patience_counter = 0 - self.save_checkpoint('best.pt', epoch, val_loss) + self.save_checkpoint("best.pt", epoch, val_loss) print("모델 저장 (best)") else: self.patience_counter += 1 @@ -109,17 +106,20 @@ def train(self, epochs: int, resume_from: str | Path | None = None): def save_checkpoint(self, filename: str, epoch: int, val_loss: float): """체크포인트 저장 (학습 상태 포함)""" - torch.save({ - 'epoch': epoch, - 'model_state_dict': self.model.state_dict(), - 'optimizer_state_dict': self.optimizer.state_dict(), - 'scheduler_state_dict': self.scheduler.state_dict(), - 'val_loss': val_loss, - 'config': { - 'model_class': self.model.__class__.__name__, - 'model_args': self.model.config, - } - }, self.checkpoint_dir / filename) + torch.save( + { + "epoch": epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "scheduler_state_dict": self.scheduler.state_dict(), + "val_loss": val_loss, + "config": { + "model_class": self.model.__class__.__name__, + "model_args": self.model.config, + }, + }, + self.checkpoint_dir / filename, + ) print(f"Saved checkpoint: {self.checkpoint_dir / filename}") def load_checkpoint(self, filepath: str | Path) -> int: @@ -127,18 +127,18 @@ def load_checkpoint(self, filepath: str | Path) -> int: checkpoint = torch.load(filepath, map_location=self.device) # 모델 상태 로드 - self.model.load_state_dict(checkpoint['model_state_dict']) + 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 "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']) + if "scheduler_state_dict" in checkpoint: + self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"]) - self.best_val_loss = checkpoint.get('val_loss', float('inf')) + self.best_val_loss = checkpoint.get("val_loss", float("inf")) - loaded_epoch = checkpoint.get('epoch', 0) + loaded_epoch = checkpoint.get("epoch", 0) print(f"✅ 체크포인트 로드 완료: {filepath} (에포크 {loaded_epoch})") return loaded_epoch diff --git a/runs/exp03_physics_fused/dataset_fused.py b/runs/exp03_physics_fused/dataset_fused.py index 590b51f..1132f59 100644 --- a/runs/exp03_physics_fused/dataset_fused.py +++ b/runs/exp03_physics_fused/dataset_fused.py @@ -10,11 +10,7 @@ class FusedDatasetH5(Dataset): - def __init__(self, h5_path: str | Path, - mode: str = "train", - val_ratio: float = 0.2, - norm_ranges: dict | None = None - ): + def __init__(self, h5_path: str | Path, mode: str = "train", val_ratio: float = 0.2, norm_ranges: dict | None = None): self.h5_path = Path(h5_path) self.mode = mode if not self.h5_path.exists(): @@ -30,11 +26,7 @@ def __init__(self, h5_path: str | Path, # Ranges if norm_ranges is None: - norm_ranges = { - "thickness": [0.0, 200.0], - "roughness" : [0.0, 10.0], - "sld" : [0.0, 140.0] - } + norm_ranges = {"thickness": [0.0, 200.0], "roughness": [0.0, 10.0], "sld": [0.0, 140.0]} self.thickness_range = norm_ranges["thickness"] self.roughness_range = norm_ranges["roughness"] self.sld_range = norm_ranges["sld"] @@ -56,17 +48,16 @@ def __getitem__(self, idx): rough_norm = self._normalize(rough, self.roughness_range) sld_norm = self._normalize(sld, self.sld_range) - targets = torch.stack([ - torch.tensor(thick_norm, dtype=torch.float32), - torch.tensor(rough_norm, dtype=torch.float32), - torch.tensor(sld_norm, dtype=torch.float32) - ], dim=-1) + targets = torch.stack( + [ + torch.tensor(thick_norm, dtype=torch.float32), + torch.tensor(rough_norm, dtype=torch.float32), + torch.tensor(sld_norm, dtype=torch.float32), + ], + dim=-1, + ) - return { - "reflectivity": torch.from_numpy(refl), - "targets": targets, - "q_values": self.q_values - } + return {"reflectivity": torch.from_numpy(refl), "targets": targets, "q_values": self.q_values} @staticmethod def _normalize(values: np.ndarray, value_range: tuple) -> np.ndarray: diff --git a/runs/exp03_physics_fused/main.py b/runs/exp03_physics_fused/main.py index 201890d..0bc72ef 100644 --- a/runs/exp03_physics_fused/main.py +++ b/runs/exp03_physics_fused/main.py @@ -13,12 +13,7 @@ def main(): "exp_name": "exp04_physics_fused", "h5_path": Path(os.environ["DATA"], r"XRR_AI\data/xrr_data.h5"), "checkpoint_dir": Path(os.environ["DATA"], r"XRR_AI\model\exp001_reg_tanh"), - "norm_ranges": { - "thickness": [20.0, 150.0], - "roughness": [0.0, 10.0], - "sld": [0.0, 140.0] - }, - + "norm_ranges": {"thickness": [20.0, 150.0], "roughness": [0.0, 10.0], "sld": [0.0, 140.0]}, "epochs": 20, "batch_size": 32, "lr": 1e-3, diff --git a/runs/exp03_physics_fused/model_fused.py b/runs/exp03_physics_fused/model_fused.py index 411f141..f3a94b6 100644 --- a/runs/exp03_physics_fused/model_fused.py +++ b/runs/exp03_physics_fused/model_fused.py @@ -7,11 +7,8 @@ logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('physics_debug.log', mode='w'), - logging.StreamHandler(sys.stdout) - ] + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("physics_debug.log", mode="w"), logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger(__name__) @@ -22,13 +19,10 @@ def __init__(self, in_channels=1, base_channels=32, n_layers=4, kernel_size=7): layers = [] ch = in_channels for i in range(n_layers): - out_ch = base_channels * (2 ** i) if i < 3 else base_channels * 8 - layers.extend([ - nn.Conv1d(ch, out_ch, kernel_size, padding=kernel_size//2), - nn.BatchNorm1d(out_ch), - nn.ReLU(inplace=True), - nn.MaxPool1d(2) - ]) + out_ch = base_channels * (2**i) if i < 3 else base_channels * 8 + layers.extend( + [nn.Conv1d(ch, out_ch, kernel_size, padding=kernel_size // 2), nn.BatchNorm1d(out_ch), nn.ReLU(inplace=True), nn.MaxPool1d(2)] + ) ch = out_ch self.net = nn.Sequential(*layers) self.out_channels = ch @@ -40,8 +34,7 @@ def forward(self, x): class FusedPhysicsRegressor(nn.Module): - def __init__(self, q_len: int, n_layers: int, q_values: torch.Tensor, - physics_weight: float = 0.5, norm_ranges: dict | None = None): + def __init__(self, q_len: int, n_layers: int, q_values: torch.Tensor, physics_weight: float = 0.5, norm_ranges: dict | None = None): super().__init__() self.n_layers = n_layers self.physics_weight = physics_weight @@ -51,18 +44,9 @@ def __init__(self, q_len: int, n_layers: int, q_values: torch.Tensor, self.physics_layer = PhysicsLayer(q_values, n_layers, confidence_threshold=0.1) fusion_dim = self.encoder.out_channels + n_layers - self.regressor = nn.Sequential( - nn.Linear(fusion_dim, 128), - nn.ReLU(), - nn.Dropout(0.2), - nn.Linear(128, n_layers * 3) - ) + self.regressor = nn.Sequential(nn.Linear(fusion_dim, 128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, n_layers * 3)) if norm_ranges is None: - norm_ranges = { - "thickness": [0.0, 200.0], - "roughness" : [0.0, 10.0], - "sld" : [0.0, 140.0] - } + norm_ranges = {"thickness": [0.0, 200.0], "roughness": [0.0, 10.0], "sld": [0.0, 140.0]} self.register_buffer("th_min", torch.tensor(norm_ranges["thickness"][0])) self.register_buffer("th_max", torch.tensor(norm_ranges["thickness"][1])) @@ -100,7 +84,7 @@ def forward(self, x: torch.Tensor) -> dict: "validity_mask": validity_mask, "nn_raw": torch.stack([th, rg, sld], dim=-1), "final": torch.stack([final_th, rg, sld], dim=-1), - "confidence": validity_mask.float() + "confidence": validity_mask.float(), } @@ -112,10 +96,7 @@ def fused_loss_with_masking(outputs, targets, physics_weight=0.5): validity_mask = outputs["validity_mask"] if validity_mask.sum() > 0: - loss_physics = torch.mean( - ((physics_pred - targets[:, :, 0]) ** 2) * - validity_mask.unsqueeze(1) - ) + loss_physics = torch.mean(((physics_pred - targets[:, :, 0]) ** 2) * validity_mask.unsqueeze(1)) else: loss_physics = torch.tensor(0.0, device=targets.device) diff --git a/runs/exp03_physics_fused/physics.py b/runs/exp03_physics_fused/physics.py index a929b06..ce73124 100644 --- a/runs/exp03_physics_fused/physics.py +++ b/runs/exp03_physics_fused/physics.py @@ -11,13 +11,11 @@ def func_gauss(x, a, p, w): - return a * np.exp(-np.log(2) * ((x - p) / (w / 2))**2) + return a * np.exp(-np.log(2) * ((x - p) / (w / 2)) ** 2) def func_gauss3(x, a1, p1, w1, a2, p2, w2, a3, p3, w3): - return (func_gauss(x, a1, p1, w1) + - func_gauss(x, a2, p2, w2) + - func_gauss(x, a3, p3, w3)) + return func_gauss(x, a1, p1, w1) + func_gauss(x, a2, p2, w2) + func_gauss(x, a3, p3, w3) def xrr_fft(x, y, d=None, window=2, n=None, step_num: int = 1000): @@ -102,7 +100,7 @@ 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 로직 적용 + 피팅""" + """valid_comb 로직 적용 + 피팅""" # 1. Baseline 제거 R_baseline_removed = self._remove_baseline(R) @@ -163,10 +161,7 @@ def _process_single_with_fitting(self, q: np.ndarray, R: np.ndarray): # bounds = (bounds_lower, bounds_upper) # 피팅 - popt, pcov = curve_fit( - func_gauss3, x_fit, y_fit, - p0=p0, maxfev=2000 - ) + popt, pcov = curve_fit(func_gauss3, x_fit, y_fit, p0=p0, maxfev=2000) # 품질 점수 (covariance trace) score = 1.0 / (1.0 + np.trace(pcov)) @@ -215,9 +210,8 @@ def _select_unique_peaks(self, peaks: np.ndarray, y_fft: np.ndarray, x_fft: np.n def _remove_baseline(self, R: np.ndarray) -> np.ndarray: R_log = np.log10(np.clip(R, 1e-12, None)) window_len = len(R_log) - R_smooth = signal.savgol_filter(R_log, window_length=window_len, - polyorder=self.baseline_polyorder, mode='interp') - R_baseline = 10 ** R_smooth + R_smooth = signal.savgol_filter(R_log, window_length=window_len, polyorder=self.baseline_polyorder, mode="interp") + R_baseline = 10**R_smooth R_savgol = R / (R_baseline + 1e-12) R_savgol = R_savgol / (R_savgol[0] + 1e-12) * R.max() return R_savgol @@ -229,12 +223,12 @@ def _compute_fft(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.nda return x_fft, y_fft -if __name__ == '__main__': +if __name__ == "__main__": from reflecto_exp.physics_utils import tth2q from reflecto_exp.simulate.simul_refnx import XRRSimulator wavelen: float = 1.54 # (nm) - tth_min: float = 0.2 # degree + tth_min: float = 0.2 # degree tth_max: float = 6.0 tth_n: int = 300 tths: np.ndarray = np.linspace(tth_min, tth_max, tth_n) @@ -259,4 +253,4 @@ def _compute_fft(self, q: np.ndarray, R: np.ndarray) -> tuple[np.ndarray, np.nda print(f"추정 두께: {est_thickness[0].numpy()} Å") print(confidence) print(f"실제 두께: {thicknesses} Å") - print(f"MAE: {np.mean(np.abs(est_thickness[0].numpy()[:len(thicknesses)] - thicknesses)):.2f} Å") + print(f"MAE: {np.mean(np.abs(est_thickness[0].numpy()[: len(thicknesses)] - thicknesses)):.2f} Å") diff --git a/runs/exp03_physics_fused/test/fourier_test.ipynb b/runs/exp03_physics_fused/test/fourier_test.ipynb index 0a26d01..3ac90eb 100644 --- a/runs/exp03_physics_fused/test/fourier_test.ipynb +++ b/runs/exp03_physics_fused/test/fourier_test.ipynb @@ -27,7 +27,7 @@ "from reflecto.simulate.simul_genx import ParamSet, param2refl\n", "\n", "wavelen: float = 1.54 # (nm)\n", - "tth_min: float = 0.1 # degree\n", + "tth_min: float = 0.1 # degree\n", "tth_max: float = 6.0\n", "tth_n: int = 300\n", "tths: np.ndarray = np.linspace(tth_min, tth_max, tth_n)\n", @@ -183,31 +183,31 @@ " fig, axes = plt.subplots(3, 1, figsize=(8, 8))\n", "\n", " # 원본 데이터\n", - " axes[0].semilogy(q, I, 'b-', alpha=0.5, label='Original')\n", - " axes[0].semilogy(q, 10**log_I_smooth, 'r-', label='Smoothed')\n", - " axes[0].axvline(qc, color='g', linestyle='--', linewidth=2, label=f'Qc = {qc:.4f} Å⁻¹')\n", - " axes[0].set_xlabel('Q (Å⁻¹)')\n", - " axes[0].set_ylabel('Intensity')\n", + " axes[0].semilogy(q, I, \"b-\", alpha=0.5, label=\"Original\")\n", + " axes[0].semilogy(q, 10**log_I_smooth, \"r-\", label=\"Smoothed\")\n", + " axes[0].axvline(qc, color=\"g\", linestyle=\"--\", linewidth=2, label=f\"Qc = {qc:.4f} Å⁻¹\")\n", + " axes[0].set_xlabel(\"Q (Å⁻¹)\")\n", + " axes[0].set_ylabel(\"Intensity\")\n", " axes[0].legend()\n", " axes[0].grid(True, alpha=0.3)\n", - " axes[0].set_title('Reflectivity Data')\n", + " axes[0].set_title(\"Reflectivity Data\")\n", "\n", " # 1차 미분\n", - " axes[1].plot(q, dI_dq, 'b-')\n", - " axes[1].axvline(qc, color='g', linestyle='--', linewidth=2)\n", - " axes[1].set_xlabel('Q (Å⁻¹)')\n", - " axes[1].set_ylabel('dlog(I)/dQ')\n", + " axes[1].plot(q, dI_dq, \"b-\")\n", + " axes[1].axvline(qc, color=\"g\", linestyle=\"--\", linewidth=2)\n", + " axes[1].set_xlabel(\"Q (Å⁻¹)\")\n", + " axes[1].set_ylabel(\"dlog(I)/dQ\")\n", " axes[1].grid(True, alpha=0.3)\n", - " axes[1].set_title('First Derivative')\n", + " axes[1].set_title(\"First Derivative\")\n", "\n", " # 2차 미분\n", - " axes[2].plot(q, d2I_dq2, 'r-')\n", - " axes[2].axvline(qc, color='g', linestyle='--', linewidth=2, label=f'Min at Qc = {qc:.4f}')\n", - " axes[2].set_xlabel('Q (Å⁻¹)')\n", - " axes[2].set_ylabel('d²log(I)/dQ²')\n", + " axes[2].plot(q, d2I_dq2, \"r-\")\n", + " axes[2].axvline(qc, color=\"g\", linestyle=\"--\", linewidth=2, label=f\"Min at Qc = {qc:.4f}\")\n", + " axes[2].set_xlabel(\"Q (Å⁻¹)\")\n", + " axes[2].set_ylabel(\"d²log(I)/dQ²\")\n", " axes[2].legend()\n", " axes[2].grid(True, alpha=0.3)\n", - " axes[2].set_title('Second Derivative (finds inflection point)')\n", + " axes[2].set_title(\"Second Derivative (finds inflection point)\")\n", " axes[2].set_xlim(0, min(0.15, q.max()))\n", "\n", " plt.tight_layout()\n", @@ -215,6 +215,7 @@ "\n", " return qc\n", "\n", + "\n", "qc = find_qc_derivative(qs, refl, plot=True)\n", "print(f\"Critical Q is {qc}\")" ] @@ -254,22 +255,23 @@ "\n", "\n", "D, T = transform(\n", - " qs, refl,\n", + " qs,\n", + " refl,\n", " trans_type=TransformType.mexican_hat_WT, # Wavelet 변환\n", " Qc=qc,\n", " Q4=True,\n", - " logI=False, # 로그 스케일\n", - " Qmin=0, # 낮은 Q 제거\n", - " Qmax=None, # 높은 Q 제거\n", - " wavelet_scaling=0.5, # Wavelet 스케일링\n", - " avg_correction=None\n", + " logI=False, # 로그 스케일\n", + " Qmin=0, # 낮은 Q 제거\n", + " Qmax=None, # 높은 Q 제거\n", + " wavelet_scaling=0.5, # Wavelet 스케일링\n", + " avg_correction=None,\n", ")\n", "\n", "peaks, properties = find_peaks(T)\n", "print(f\"Peak 위치 (x값): {D[peaks]}\")\n", "# print(thickness)\n", - "plt.plot(D, T, label='Data')\n", - "plt.plot(D[peaks], T[peaks], 'rx', markersize=10, label='Peaks')\n", + "plt.plot(D, T, label=\"Data\")\n", + "plt.plot(D[peaks], T[peaks], \"rx\", markersize=10, label=\"Peaks\")\n", "plt.legend()\n", "plt.show()\n", "\n", @@ -294,11 +296,13 @@ } ], "source": [ - "D, T = transform(qs, refl,\n", - " Qc=0.02, # 임계 Q 보정\n", - " Q4=True, # Fresnel 보정\n", - " derivate=True # 미분으로 평탄화 제거\n", - " )\n", + "D, T = transform(\n", + " qs,\n", + " refl,\n", + " Qc=0.02, # 임계 Q 보정\n", + " Q4=True, # Fresnel 보정\n", + " derivate=True, # 미분으로 평탄화 제거\n", + ")\n", "fig, ax = plt.subplots(1, 1, figsize=(6, 4))\n", "ax.plot(D, T)\n", "# ax.set_yscale(\"log\")\n", @@ -326,11 +330,12 @@ ], "source": [ "D, T = transform(\n", - " qs, refl,\n", - " avg_correction='subtract', # 또는 'divide'\n", - " avgN=20, # 평균 계산 점 개수\n", + " qs,\n", + " refl,\n", + " avg_correction=\"subtract\", # 또는 'divide'\n", + " avgN=20, # 평균 계산 점 개수\n", " Q4=True,\n", - " derivate=True\n", + " derivate=True,\n", ")\n", "fig, ax = plt.subplots(1, 1, figsize=(6, 4))\n", "ax.plot(D, T)\n", @@ -358,24 +363,25 @@ ], "source": [ "Q_corrected, I_corrected, D, T = transform(\n", - " qs, refl,\n", + " qs,\n", + " refl,\n", " Qc=0.02,\n", " Q4=True,\n", " derivate=True,\n", - " return_corrected=True # 보정된 Q, I도 반환\n", + " return_corrected=True, # 보정된 Q, I도 반환\n", ")\n", "\n", "# 보정 전후 비교\n", "plt.figure(figsize=(12, 4))\n", "plt.subplot(121)\n", - "plt.plot(qs, refl, label='Original')\n", - "plt.xlabel('Q')\n", - "plt.ylabel('Intensity')\n", + "plt.plot(qs, refl, label=\"Original\")\n", + "plt.xlabel(\"Q\")\n", + "plt.ylabel(\"Intensity\")\n", "\n", "plt.subplot(122)\n", - "plt.plot(Q_corrected, I_corrected, label='Corrected')\n", - "plt.xlabel('Q (corrected)')\n", - "plt.ylabel('Intensity (corrected)')\n", + "plt.plot(Q_corrected, I_corrected, label=\"Corrected\")\n", + "plt.xlabel(\"Q (corrected)\")\n", + "plt.ylabel(\"Intensity (corrected)\")\n", "# plt.yscale(\"log\")\n", "plt.show()" ] diff --git a/runs/exp03_physics_fused/test/test.ipynb b/runs/exp03_physics_fused/test/test.ipynb index 0d26dad..4fc3e54 100644 --- a/runs/exp03_physics_fused/test/test.ipynb +++ b/runs/exp03_physics_fused/test/test.ipynb @@ -28,7 +28,7 @@ "import os\n", "from pathlib import Path\n", "\n", - "if '__setup_done' not in globals():\n", + "if \"__setup_done\" not in globals():\n", " os.chdir(\"..\")\n", " __setup_done = True\n", " print(\"디렉토리 변경 완료\")\n", @@ -73,7 +73,7 @@ "from reflecto.simulate.simul_genx import XRRSimulator\n", "\n", "wavelen: float = 1.54 # (nm)\n", - "tth_min: float = 0.2 # degree\n", + "tth_min: float = 0.2 # degree\n", "tth_max: float = 6.0\n", "tth_n: int = 300\n", "tths: np.ndarray = np.linspace(tth_min, tth_max, tth_n)\n", @@ -153,7 +153,7 @@ "print(\"\\nPhysicsLayer 추정 결과:\")\n", "print(f\"추정 두께: {est_thickness[0].numpy()} Å\")\n", "print(f\"실제 두께: {thicknesses} Å\")\n", - "print(f\"MAE: {np.mean(np.abs(est_thickness[0].numpy()[:len(thicknesses)] - thicknesses)):.2f} Å\")" + "print(f\"MAE: {np.mean(np.abs(est_thickness[0].numpy()[: len(thicknesses)] - thicknesses)):.2f} Å\")" ] }, { @@ -304,19 +304,19 @@ "# FFT 계산 (PhysicsLayer 내부 과정)\n", "window_len = len(refl)\n", "\n", - "R_smooth = signal.savgol_filter(np.log10(refl), window_length=window_len, polyorder=4, mode='interp')\n", - "savgol = 10 ** R_smooth\n", + "R_smooth = signal.savgol_filter(np.log10(refl), window_length=window_len, polyorder=4, mode=\"interp\")\n", + "savgol = 10**R_smooth\n", "R_savgol = refl / savgol\n", "R_savgol = R_savgol / (R_savgol[0] + 1e-12) * refl.max()\n", "# R_smooth = signal.savgol_filter(np.log10(refl), window_length=window_len, polyorder=4, mode='interp')\n", "\n", "plt.figure()\n", - "plt.plot(tths, refl, label='Original')\n", - "plt.plot(tths, R_savgol, label=' / savgol', color='red')\n", - "plt.plot(tths, savgol, label='Trend (smooth)', linestyle='--')\n", - "plt.xlabel('2 theta')\n", - "plt.ylabel('R(q)')\n", - "plt.yscale('log')\n", + "plt.plot(tths, refl, label=\"Original\")\n", + "plt.plot(tths, R_savgol, label=\" / savgol\", color=\"red\")\n", + "plt.plot(tths, savgol, label=\"Trend (smooth)\", linestyle=\"--\")\n", + "plt.xlabel(\"2 theta\")\n", + "plt.ylabel(\"R(q)\")\n", + "plt.yscale(\"log\")\n", "plt.legend()\n", "plt.show()" ] @@ -342,7 +342,7 @@ " # 대칭 중심\n", " t = np.arange(-(M // 2), M // 2)\n", " # Morlet wavelet\n", - " wave = np.exp(1j * w * t / s) * np.exp(-(t ** 2) / (2 * s ** 2))\n", + " wave = np.exp(1j * w * t / s) * np.exp(-(t**2) / (2 * s**2))\n", " # L2 normalization\n", " wave /= np.sqrt(np.sum(np.abs(wave) ** 2))\n", " return wave\n", @@ -387,7 +387,7 @@ "\n", " for i, width in enumerate(widths):\n", " wavelet = morlet2_custom(M=N, s=width, w=w)\n", - " conv = signal.fftconvolve(y, wavelet, mode='same')\n", + " conv = signal.fftconvolve(y, wavelet, mode=\"same\")\n", " cwtmatr[i, :] = conv\n", "\n", " # ----- 5) pseudo-frequency -----\n", @@ -396,7 +396,7 @@ " # ----- 6) 파워 스펙트럼 -----\n", " yf = np.abs(cwtmatr).mean(axis=1)\n", "\n", - " return xf, yf\n" + " return xf, yf" ] }, { @@ -453,16 +453,16 @@ "fig, ax = plt.subplots(1, 1, figsize=(7, 5))\n", "\n", "# FFT 및 피크\n", - "ax.plot(x_fft, y_fft, 'g-', linewidth=1, label='FFT Amplitude')\n", + "ax.plot(x_fft, y_fft, \"g-\", linewidth=1, label=\"FFT Amplitude\")\n", "if len(peak_indices) > 0:\n", - " ax.plot(x_fft[peak_indices], y_fft[peak_indices], 'ro', markersize=8, label=f'Detected Peaks ({len(peak_indices)})')\n", + " ax.plot(x_fft[peak_indices], y_fft[peak_indices], \"ro\", markersize=8, label=f\"Detected Peaks ({len(peak_indices)})\")\n", "\n", "thicknesses_comb = [sum(set(comb)) for comb in combinations_with_replacement(thicknesses, 2)]\n", "thick_comb_idx = [np.argmin(np.abs(x_fft - p)) for p in thicknesses_comb]\n", - "ax.plot(thicknesses_comb, y_fft[thick_comb_idx], 'bx', markersize=8, label=f'True thickness combs ({len(thicknesses_comb)})')\n", + "ax.plot(thicknesses_comb, y_fft[thick_comb_idx], \"bx\", markersize=8, label=f\"True thickness combs ({len(thicknesses_comb)})\")\n", "ax.set_xlim(0, x_upper)\n", "# ax.set_xlabel('thickness (nm)')\n", - "ax.set_ylabel('Amplitude')\n", + "ax.set_ylabel(\"Amplitude\")\n", "\n", "ax.legend()\n", "ax.grid(True, alpha=0.3)\n", @@ -499,6 +499,8 @@ " if abs(c - a - b) < 5:\n", " result.append([a, b, c])\n", " return result\n", + "\n", + "\n", "valid_combs = valid_comb(x_fft[peak_indices])\n", "print(valid_combs)" ] diff --git a/runs/exp03_physics_fused/test/test.py b/runs/exp03_physics_fused/test/test.py index b51019b..da1e065 100644 --- a/runs/exp03_physics_fused/test/test.py +++ b/runs/exp03_physics_fused/test/test.py @@ -22,7 +22,7 @@ def __init__(self, h5_path: str | Path, norm_ranges: dict = None, device: str = h5_path, mode="val", # 검증셋으로 테스트 val_ratio=0.2, - norm_ranges=norm_ranges + norm_ranges=norm_ranges, ) # q_values 추출 @@ -30,18 +30,12 @@ def __init__(self, h5_path: str | Path, norm_ranges: dict = None, device: str = 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.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] - } + 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: """정규화된 두께를 원래 스케일로 복원""" @@ -67,7 +61,7 @@ def validate_sample(self, idx: int, plot: bool = False, save_path: str = None): confidence = validity_mask[0].item() # 오차 계산 - mae = np.mean(np.abs(est_thickness[:len(real_thickness)] - real_thickness)) + mae = np.mean(np.abs(est_thickness[: len(real_thickness)] - real_thickness)) results = { "idx": idx, @@ -75,17 +69,12 @@ def validate_sample(self, idx: int, plot: bool = False, save_path: str = None): "est_thickness": est_thickness, "confidence": confidence, "mae": mae, - "is_valid": confidence > 0.1 + "is_valid": confidence > 0.1, } # 시각화 if plot: - self.plot_physics_process( - sample, - physics_output.cpu(), - validity_mask.cpu(), - save_path=save_path - ) + self.plot_physics_process(sample, physics_output.cpu(), validity_mask.cpu(), save_path=save_path) return results @@ -117,7 +106,7 @@ def validate_batch(self, n_samples: int = 100): "overall_mae": np.mean(all_mae), "valid_mae": np.mean(valid_mae) if valid_mae else 0.0, "invalid_mae": np.mean(invalid_mae) if invalid_mae else 0.0, - "mae_std": np.std(all_mae) + "mae_std": np.std(all_mae), } return stats, indices, all_mae @@ -128,42 +117,43 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: q_np = self.q_values.numpy() R_log_np = sample["reflectivity"].numpy() - R_original = 10 ** R_log_np + 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') - axes[0].set_title('Original Reflectivity') + axes[0].plot(q_np, R_original, "b-", label="Reflectivity (R)") + axes[0].set_xlabel("q") + axes[0].set_ylabel("R") + axes[0].set_title("Original Reflectivity") axes[0].legend() - axes[0].set_yscale('log') + 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_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() N = len(R_savgol) w = signal.windows.hamming(N) yf = 2 / N * np.abs(fftpack.fft(w * R_savgol / np.mean(w), n=10000)) - xf = fftpack.fftfreq(10000, d=q_np[1]-q_np[0]) + xf = fftpack.fftfreq(10000, d=q_np[1] - q_np[0]) xf, yf = xf[:5000], yf[:5000] x_fft = xf * 2 * np.pi - axes[1].plot(x_fft, yf, 'g-', label='FFT') + axes[1].plot(x_fft, yf, "g-", label="FFT") # 피크 강조 from scipy.signal import argrelmax + upper_idx = np.searchsorted(x_fft, 200.0) for order in [50, 30, 20, 10]: idx_max = argrelmax(yf[:upper_idx], order=order)[0] if len(idx_max) > 0: - axes[1].plot(x_fft[idx_max], yf[idx_max], 'ro', label=f'Peaks (order={order})') + axes[1].plot(x_fft[idx_max], yf[idx_max], "ro", label=f"Peaks (order={order})") break - axes[1].set_xlabel('Frequency (1/thickness)') - axes[1].set_ylabel('Amplitude') - axes[1].set_title('FFT Analysis & Peak Detection') + axes[1].set_xlabel("Frequency (1/thickness)") + axes[1].set_ylabel("Amplitude") + axes[1].set_title("FFT Analysis & Peak Detection") axes[1].legend() # 3. 두께 비교 @@ -173,56 +163,59 @@ def plot_physics_process(self, sample, physics_output, validity_mask, save_path: layers = np.arange(len(real_thickness)) width = 0.35 - axes[2].bar(layers - width/2, real_thickness, width, label='Real', alpha=0.8) - axes[2].bar(layers + width/2, est_thickness[:len(real_thickness)], width, - label='Physics Est.', alpha=0.8) + axes[2].bar(layers - width / 2, real_thickness, width, label="Real", alpha=0.8) + axes[2].bar(layers + width / 2, est_thickness[: len(real_thickness)], width, label="Physics Est.", alpha=0.8) - axes[2].set_xlabel('Layer Index') - axes[2].set_ylabel('Thickness (Å)') - axes[2].set_title(f'Thickness Comparison (Confidence: {validity_mask[0].item():.3f})') + axes[2].set_xlabel("Layer Index") + axes[2].set_ylabel("Thickness (Å)") + 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, 0.98, f'MAE: {mae:.2f} Å', - transform=axes[2].transAxes, - verticalalignment='top', - bbox={"boxstyle": 'round', "facecolor": 'wheat', "alpha": 0.5}) + mae = np.mean(np.abs(est_thickness[: len(real_thickness)] - real_thickness)) + axes[2].text( + 0.02, + 0.98, + f"MAE: {mae:.2f} Å", + transform=axes[2].transAxes, + verticalalignment="top", + bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5}, + ) plt.tight_layout() if save_path: - plt.savefig(save_path, dpi=150, bbox_inches='tight') + plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close() else: plt.show() def print_summary(self, stats: dict): """검증 결과 요약 출력""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("📊 Physics Layer Validation Summary") - print("="*60) + print("=" * 60) print(f"Total samples: {stats['total_samples']}") print(f"Valid predictions: {stats['valid_samples']} ({stats['success_rate']:.2%})") print(f"Average confidence: {stats['avg_confidence']:.3f}") print(f"\nThickness MAE (all samples): {stats['overall_mae']:.2f} Å") - if stats['valid_mae'] > 0: + if stats["valid_mae"] > 0: print(f"Thickness MAE (valid only): {stats['valid_mae']:.2f} Å") - if stats['invalid_mae'] > 0: + if stats["invalid_mae"] > 0: print(f"Thickness MAE (invalid only): {stats['invalid_mae']:.2f} Å") print(f"MAE std: {stats['mae_std']:.2f} Å") - print("="*60) + 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') - axes[0].set_title('Thickness MAE Distribution') - axes[0].axvline(np.mean(mae_list), color='r', linestyle='--', label=f'Mean: {np.mean(mae_list):.2f}') + axes[0].hist(mae_list, bins=20, edgecolor="black", alpha=0.7) + axes[0].set_xlabel("MAE (Å)") + axes[0].set_ylabel("Frequency") + axes[0].set_title("Thickness MAE Distribution") + 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 산점도 @@ -235,16 +228,16 @@ def plot_error_distribution(self, indices, mae_list, save_path: str | Path = Non confidences.append(validity_mask[0].item()) axes[1].scatter(confidences, mae_list, alpha=0.6) - axes[1].set_xlabel('Confidence') - axes[1].set_ylabel('MAE (Å)') - axes[1].set_title('Confidence vs MAE') - axes[1].axvline(0.1, color='r', linestyle='--', label='Threshold (0.1)') + axes[1].set_xlabel("Confidence") + axes[1].set_ylabel("MAE (Å)") + axes[1].set_title("Confidence vs MAE") + axes[1].axvline(0.1, color="r", linestyle="--", label="Threshold (0.1)") axes[1].legend() plt.tight_layout() if save_path: - plt.savefig(save_path, dpi=150, bbox_inches='tight') + plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close() else: plt.show() @@ -256,10 +249,7 @@ def main(): 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]} - ) + 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) @@ -270,21 +260,14 @@ def main(): validator.print_summary(stats) # 오차 분포 플롯 - validator.plot_error_distribution( - indices, mae_list, - save_path=plot_dir / "error_distribution.png" - ) + 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) for idx in plot_indices: - validator.validate_sample( - idx, - plot=True, - save_path=plot_dir / f"sample_{idx}_analysis.png" - ) + validator.validate_sample(idx, plot=True, save_path=plot_dir / f"sample_{idx}_analysis.png") print(f"\nValidation complete! Plots saved to {plot_dir}") diff --git a/runs/exp03_physics_fused/train_fused.py b/runs/exp03_physics_fused/train_fused.py index 5005ee0..575edd0 100644 --- a/runs/exp03_physics_fused/train_fused.py +++ b/runs/exp03_physics_fused/train_fused.py @@ -23,32 +23,14 @@ def __init__(self, config: dict): 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") - ) + 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")) self.train_loader = DataLoader( - self.train_dataset, - batch_size=config["batch_size"], - shuffle=True, - num_workers=config.get("num_workers", 4), - pin_memory=True + self.train_dataset, batch_size=config["batch_size"], shuffle=True, num_workers=config.get("num_workers", 4), pin_memory=True ) self.val_loader = DataLoader( - self.val_dataset, - batch_size=config["batch_size"], - shuffle=False, - num_workers=config.get("num_workers", 4), - pin_memory=True + self.val_dataset, batch_size=config["batch_size"], shuffle=False, num_workers=config.get("num_workers", 4), pin_memory=True ) # 2. Model 초기화 (정규화 범위 전달) @@ -58,20 +40,11 @@ def __init__(self, config: dict): q_values = sample["q_values"] self.model = FusedPhysicsRegressor( - 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") + 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) - ) + 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): self.scheduler = OneCycleLR( @@ -81,16 +54,10 @@ def __init__(self, config: dict): steps_per_epoch=len(self.train_loader), pct_start=0.3, div_factor=25.0, - final_div_factor=1e4 + final_div_factor=1e4, ) else: - self.scheduler = ReduceLROnPlateau( - self.optimizer, - mode="min", - patience=5, - factor=0.5, - min_lr=1e-6 - ) + 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 @@ -148,19 +115,13 @@ def train_epoch(self): physics_confidences.append(conf.mean().item()) physics_contributions.append((conf * self.config["physics_weight"]).mean().item()) - pbar.set_postfix({ - "loss": f"{batch_loss:.4f}", - "conf": f"{conf.mean().item():.3f}" - }) + pbar.set_postfix({"loss": f"{batch_loss:.4f}", "conf": f"{conf.mean().item():.3f}"}) avg_loss = epoch_loss / len(self.train_loader) avg_confidence = np.mean(physics_confidences) avg_physics_contrib = np.mean(physics_contributions) - return avg_loss, { - "confidence": avg_confidence, - "physics_contrib": avg_physics_contrib - } + return avg_loss, {"confidence": avg_confidence, "physics_contrib": avg_physics_contrib} def validate(self): """검증 실행""" @@ -195,10 +156,7 @@ def validate(self): avg_confidence = np.mean(physics_confidences) param_mae = {k: np.mean(v) for k, v in param_errors.items()} - return avg_loss, { - "confidence": avg_confidence, - "param_mae": param_mae - } + return avg_loss, {"confidence": avg_confidence, "param_mae": param_mae} def train(self): """전체 학습 루프""" @@ -270,7 +228,7 @@ def save_checkpoint(self, filename: str, epoch: int, val_loss: float, metrics: d "scaler_state_dict": self.scaler.state_dict() if self.scaler else None, "val_loss": val_loss, "config": self.config, - "metrics": metrics + "metrics": metrics, } torch.save(checkpoint, self.ckpt_dir / filename) diff --git a/runs/exp04_one_genx/dataset.py b/runs/exp04_one_genx/dataset.py index 97d17ff..8178858 100644 --- a/runs/exp04_one_genx/dataset.py +++ b/runs/exp04_one_genx/dataset.py @@ -9,8 +9,7 @@ class XRR1LayerDataset(Dataset): """1-layer 전용 Dataset (Reflectivity max-normalization + log10 고정)""" - def __init__(self, h5_file: str | Path, stats_file: str | Path, - mode: str = "train", val_ratio: float = 0.2): + 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 @@ -51,19 +50,12 @@ def _setup_normalization(self): # reflectivity는 max-normalization만 하므로 평균/분산 없음 - params = np.stack([ - self.thickness[train_indices], - self.roughness[train_indices], - self.sld[train_indices] - ], axis=1) + 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) self.param_std = np.std(params, axis=0) - torch.save({ - "param_mean": torch.from_numpy(self.param_mean), - "param_std": torch.from_numpy(self.param_std) - }, self.stats_path) + torch.save({"param_mean": torch.from_numpy(self.param_mean), "param_std": torch.from_numpy(self.param_std)}, self.stats_path) print(f"정규화 파라미터 저장: {self.stats_path}") @@ -90,11 +82,7 @@ def __getitem__(self, idx): 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 = 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 params_tensor = torch.from_numpy(params_norm).float() @@ -124,5 +112,5 @@ def normalize_reflectivity(self, R_real: np.ndarray): # --------------------------------------------------------- @staticmethod def denormalize_reflectivity(R_log: np.ndarray, original_max: float): - R = 10 ** R_log + R = 10**R_log return R * original_max diff --git a/runs/exp04_one_genx/evaluate.py b/runs/exp04_one_genx/evaluate.py index 09979d8..db88784 100644 --- a/runs/exp04_one_genx/evaluate.py +++ b/runs/exp04_one_genx/evaluate.py @@ -39,15 +39,15 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file # 오류 계산 errors = preds - targets mae = torch.mean(torch.abs(errors), dim=0) - rmse = torch.sqrt(torch.mean(errors ** 2, dim=0)) + rmse = torch.sqrt(torch.mean(errors**2, dim=0)) - print("\n" + "="*50) + print("\n" + "=" * 50) print("TEST RESULTS") - print("="*50) + print("=" * 50) param_names = ["Thickness (nm)", "Roughness (Å)", "SLD"] for i, name in enumerate(param_names): print(f"{name:15s}: MAE={mae[i]:7.3f} | RMSE={rmse[i]:7.3f}") - print("="*50) + print("=" * 50) # 시각화 if save_plot: @@ -57,18 +57,18 @@ def evaluate(model, test_loader: DataLoader, stats_path: Path | str, report_file ax = axes[i] data = errors[:, i].numpy() - ax.hist(data, bins=50, density=True, alpha=0.6, color='steelblue', edgecolor='black') - ax.axvline(0, color='red', linestyle='--', linewidth=2) - ax.axvline(np.mean(data), color='orange', linestyle=':', label=f'Mean: {np.mean(data):.3f}') + ax.hist(data, bins=50, density=True, alpha=0.6, color="steelblue", edgecolor="black") + ax.axvline(0, color="red", linestyle="--", linewidth=2) + ax.axvline(np.mean(data), color="orange", linestyle=":", label=f"Mean: {np.mean(data):.3f}") - ax.set_xlabel('Prediction Error') - ax.set_ylabel('Density') - ax.set_title(f'{name}\nMAE={mae[i]:.3f}') + ax.set_xlabel("Prediction Error") + ax.set_ylabel("Density") + ax.set_title(f"{name}\nMAE={mae[i]:.3f}") ax.legend() ax.grid(alpha=0.3) plt.tight_layout() - plt.savefig(report_file, dpi=300, bbox_inches='tight') + plt.savefig(report_file, dpi=300, bbox_inches="tight") print(f"오류 분포 그래프 저장: {report_file}") return errors, mae, rmse @@ -77,13 +77,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'] + 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')) + 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) @@ -101,7 +101,6 @@ def load_checkpoint_and_evaluate(test_loader: DataLoader, checkpoint_path: Path, report_file = exp_dir / "error_distribution.png" test_set = XRR1LayerDataset(h5_file, stats_file, mode="test", val_ratio=CONFIG["training"]["val_ratio"]) - test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) + test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) load_checkpoint_and_evaluate(test_loader, checkpoint_file, stats_file, 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 498573d..809b885 100644 --- a/runs/exp04_one_genx/evaluate_with_real_data.py +++ b/runs/exp04_one_genx/evaluate_with_real_data.py @@ -26,6 +26,7 @@ def resample_spectra(meas_q: np.ndarray, meas_I: np.ndarray, target_q: np.ndarra return out[0] return out + # ------------------------------ # Helper: stats 불러와 파라미터 정규화/역정규화 # ------------------------------ @@ -35,18 +36,21 @@ def load_stats(stats_path: Path) -> tuple[np.ndarray, np.ndarray]: param_std = st["param_std"].numpy() if isinstance(st["param_std"], torch.Tensor) else np.array(st["param_std"]) return param_mean, param_std + def normalize_params(params: np.ndarray, param_mean: np.ndarray, param_std: np.ndarray) -> np.ndarray: return (params - param_mean) / param_std + def denormalize_params(params_norm: np.ndarray, param_mean: np.ndarray, param_std: np.ndarray) -> np.ndarray: 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,) + measured_I: np.ndarray, # shape (N, M) or (M,) true_params: np.ndarray | None, # shape (N, n_params) or None target_q: np.ndarray, stats_path: Path, @@ -82,11 +86,12 @@ def make_dataloader_from_arrays( loader = DataLoader(dataset, batch_size=batch_size, shuffle=False) 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')) + device = device or (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")) model = model.to(device) model.eval() @@ -115,13 +120,13 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr errors = preds - targets mae = np.mean(np.abs(errors), axis=0) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) n_params = preds.shape[1] param_names = ["Thickness (nm)", "Roughness (Å)", "SLD"][:n_params] # Print summary - print("\n" + "="*40) + print("\n" + "=" * 40) print("EVALUATION SUMMARY") for i, name in enumerate(param_names): print(f"{name:15s} MAE={mae[i]:.4f} | RMSE={rmse[i]:.4f}") @@ -132,14 +137,14 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # parity plots for i, name in enumerate(param_names): - plt.figure(figsize=(5,5)) + plt.figure(figsize=(5, 5)) plt.scatter(targets[:, i], preds[:, i], s=10) mn = min(targets[:, i].min(), preds[:, i].min()) mx = max(targets[:, i].max(), preds[:, i].max()) - plt.plot([mn, mx], [mn, mx], 'r--') - plt.xlabel('True') - plt.ylabel('Predicted') - plt.title(f'Parity: {name}') + plt.plot([mn, mx], [mn, mx], "r--") + plt.xlabel("True") + plt.ylabel("Predicted") + plt.title(f"Parity: {name}") plt.grid(alpha=0.3) plt.tight_layout() # pfile = out_prefix / f'parity_{i}_{name.replace(" ", "_")}.png' @@ -149,12 +154,12 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # residual histograms for i, name in enumerate(param_names): - plt.figure(figsize=(6,4)) + plt.figure(figsize=(6, 4)) plt.hist(errors[:, i], bins=50, density=True, alpha=0.7) - plt.axvline(0, color='k', linestyle='--') - plt.title(f'Residual histogram: {name} (MAE={mae[i]:.4f})') - plt.xlabel('Error') - plt.ylabel('Density') + plt.axvline(0, color="k", linestyle="--") + plt.title(f"Residual histogram: {name} (MAE={mae[i]:.4f})") + plt.xlabel("Error") + plt.ylabel("Density") plt.tight_layout() # pfile = out_prefix / f'resid_hist_{i}_{name.replace(" ", "_")}.png' # plt.savefig(pfile, dpi=300) @@ -164,20 +169,22 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # Optional: save per-sample spectrum comparison (measured vs reconstructed simulated from preds) # If you have forward simulator, you can reconstruct reflectivity from predicted params and compare. # Here we only save raw input R for visual inspection - np.save(out_prefix / 'preds.npy', preds) - np.save(out_prefix / 'targets.npy', targets) - np.save(out_prefix / 'Rs.npy', Rs.numpy()) + np.save(out_prefix / "preds.npy", preds) + np.save(out_prefix / "targets.npy", targets) + np.save(out_prefix / "Rs.npy", Rs.numpy()) print(f"Saved diagnostics to {out_prefix.resolve()}") return preds, targets, errors + # ------------------------------ # Example usage block (사용자 환경에 맞게 수정) # ------------------------------ if __name__ == "__main__": from config import CONFIG from model import XRR1DRegressor + # ----------------------------- # 경로 설정 # ----------------------------- @@ -188,9 +195,9 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr 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,) + 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,) try: measured_params = np.load(real_data_dir / "measured_params.npy") # (N, 3) except FileNotFoundError: @@ -199,20 +206,20 @@ def evaluate_with_plots(model, data_loader: DataLoader, stats_path: Path, out_pr # ----------------------------- # 모델 불러오기 # ----------------------------- - ckpt = torch.load(checkpoint_file, map_location='cpu') - config = ckpt['config']['model_args'] + ckpt = torch.load(checkpoint_file, 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')) + 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 - tth_max = 6.0 # deg + tth_min = 1.0 # deg + tth_max = 6.0 # deg # θ = tth/2 tth_deg = np.linspace(tth_min, tth_max, q_points) diff --git a/runs/exp04_one_genx/main.py b/runs/exp04_one_genx/main.py index 0111d5c..1954291 100644 --- a/runs/exp04_one_genx/main.py +++ b/runs/exp04_one_genx/main.py @@ -11,6 +11,7 @@ np.random.seed(42) # ========================================================================= + def main(): print("1-Layer XRR Regression 파이프라인 시작") exp_dir = CONFIG["base_dir"] / CONFIG["exp_name"] @@ -25,18 +26,16 @@ def main(): print("데이터 파일 없음, 시뮬레이션 실행...") h5_file.parent.mkdir(exist_ok=True) import simulate + simulate.generate_1layer_data(CONFIG, h5_file) train_set = XRR1LayerDataset(h5_file, stats_file, mode="train", val_ratio=CONFIG["training"]["val_ratio"]) val_set = XRR1LayerDataset(h5_file, stats_file, mode="val", val_ratio=CONFIG["training"]["val_ratio"]) test_set = XRR1LayerDataset(h5_file, stats_file, mode="test", val_ratio=CONFIG["training"]["val_ratio"]) - train_loader = DataLoader(train_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=True, num_workers=0, pin_memory=True) - val_loader = DataLoader(val_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) - test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], - shuffle=False, num_workers=0) + train_loader = DataLoader(train_set, batch_size=CONFIG["training"]["batch_size"], shuffle=True, num_workers=0, pin_memory=True) + val_loader = DataLoader(val_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) + test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) print(f"데이터셋: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") @@ -69,11 +68,12 @@ def main(): trainer.train(CONFIG["training"]["epochs"]) # 최종 평가 - print("\n" + "="*50) + print("\n" + "=" * 50) print("최종 테스트 평가") - print("="*50) + print("=" * 50) load_checkpoint_and_evaluate(test_loader, checkpoint_file, stats_file, report_file) + if __name__ == "__main__": main() diff --git a/runs/exp04_one_genx/make_one_fake.py b/runs/exp04_one_genx/make_one_fake.py index d0f0a6e..8a02128 100644 --- a/runs/exp04_one_genx/make_one_fake.py +++ b/runs/exp04_one_genx/make_one_fake.py @@ -9,7 +9,6 @@ def generate_dummy_xrr(qs, param: ParamSet): - n_layers: int = 2 n_samples: int = 1_000_000 sim: XRRSimulator = XRRSimulator(qs, n_layers, n_samples) @@ -18,11 +17,12 @@ def generate_dummy_xrr(qs, param: ParamSet): return R -if __name__ == '__main__': +if __name__ == "__main__": from pathlib import Path - # q-space 생성 (당신 config와 동일) + + # q-space 생성 (당신 config와 동일) wavelen: float = 1.54 # (nm) - tth_min: float = 1.0 # degree + tth_min: float = 1.0 # degree tth_max: float = 6.0 q_min: float = tth2q(tth_min, wavelen) # (1/Å) q_max: float = tth2q(tth_max, wavelen) diff --git a/runs/exp04_one_genx/model.py b/runs/exp04_one_genx/model.py index 6fcc361..b986b50 100644 --- a/runs/exp04_one_genx/model.py +++ b/runs/exp04_one_genx/model.py @@ -3,15 +3,14 @@ class XRR1DRegressor(nn.Module): - def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, - mlp_hidden: int = 256, dropout: float = 0.1): + def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, mlp_hidden: int = 256, dropout: float = 0.1): super().__init__() self.config = { - 'q_len': q_len, - 'n_channels': n_channels, - 'depth': depth, - 'mlp_hidden': mlp_hidden, - 'dropout': dropout, + "q_len": q_len, + "n_channels": n_channels, + "depth": depth, + "mlp_hidden": mlp_hidden, + "dropout": dropout, } # 1D CNN Encoder @@ -19,13 +18,15 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, in_ch = 1 for i in range(depth): out_ch = n_channels * (2 ** min(i, 3)) # 64, 128, 256, 512 - layers.extend([ - nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), - nn.BatchNorm1d(out_ch), - nn.ReLU(inplace=True), - nn.Dropout1d(dropout), - nn.MaxPool1d(2) - ]) + layers.extend( + [ + nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), + nn.BatchNorm1d(out_ch), + nn.ReLU(inplace=True), + nn.Dropout1d(dropout), + nn.MaxPool1d(2), + ] + ) in_ch = out_ch self.encoder = nn.Sequential(*layers) @@ -41,7 +42,7 @@ def __init__(self, q_len: int, n_channels: int = 64, depth: int = 4, 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), # 3개 파라미터 출력 ) self._init_weights() @@ -50,7 +51,7 @@ 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') + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) diff --git a/runs/exp04_one_genx/notebooks/fitting.ipynb b/runs/exp04_one_genx/notebooks/fitting.ipynb index d19f04d..b20dba5 100644 --- a/runs/exp04_one_genx/notebooks/fitting.ipynb +++ b/runs/exp04_one_genx/notebooks/fitting.ipynb @@ -28,7 +28,7 @@ "import os\n", "from pathlib import Path\n", "\n", - "if '__setup_done' not in globals():\n", + "if \"__setup_done\" not in globals():\n", " os.chdir(\"..\")\n", " __setup_done = True\n", " print(\"디렉토리 변경 완료\")\n", @@ -69,7 +69,7 @@ "from reflecto.simulate.simul_genx import ParamSet, XRRSimulator\n", "\n", "wavelen: float = 1.54 # (nm)\n", - "tth_min: float = 0.1 # degree\n", + "tth_min: float = 0.1 # degree\n", "tth_max: float = 6.0\n", "tth_n: int = 300\n", "tths: np.ndarray = np.linspace(tth_min, tth_max, tth_n)\n", @@ -134,8 +134,8 @@ "\n", "# 1) 측정 데이터 준비\n", "qz = qs\n", - "R = refl\n", - "dR = np.maximum(refl*0.3, 1e-8) # 0 에러 방지 권장\n", + "R = refl\n", + "dR = np.maximum(refl * 0.3, 1e-8) # 0 에러 방지 권장\n", "\n", "ds = DataSet(name=\"XRR 1-layer\")\n", "ds.x_raw = qz\n", @@ -229,14 +229,14 @@ "\n", "p_sld = pars.append(\"vars.set_sld\", model)\n", "p_sld.value = param.sld * 1e-5\n", - "p_sld.fit = False # 일단 끔\n", + "p_sld.fit = False # 일단 끔\n", "\n", "# [핵심] I0 파라미터 등록 및 켜기\n", "p_i0 = pars.append(\"vars.set_I0\", model)\n", - "p_i0.value = 1.0 # 초기값\n", - "p_i0.min = 0.1 # 범위 설정\n", + "p_i0.value = 1.0 # 초기값\n", + "p_i0.min = 0.1 # 범위 설정\n", "p_i0.max = 10.0\n", - "p_i0.fit = True # 이것만 켬\n", + "p_i0.fit = True # 이것만 켬\n", "\n", "model.parameters = pars\n", "\n", @@ -267,9 +267,9 @@ "# 다른 파라미터들 피팅 켜기\n", "p_thick.fit = True\n", "p_rough.fit = True\n", - "p_sld.fit = True\n", + "p_sld.fit = True\n", "# I0도 미세 조정을 위해 켜두거나, 고정하고 싶으면 False로 변경\n", - "p_i0.fit = False\n", + "p_i0.fit = False\n", "\n", "# 본격적인 피팅 (DE 또는 DREAM)\n", "res_final = model.bumps_fit(method=\"newton\", steps=1000, pop=20, tol=0.01)\n", @@ -345,18 +345,18 @@ "print(f\" Roughness (σ): {sm.Film.sigma:.4f} Å\")\n", "print(f\" Density: {sm.Film.dens}\")\n", "# X-ray의 경우 f, 중성자의 경우 b를 확인\n", - "if hasattr(sm.Film, 'f'):\n", + "if hasattr(sm.Film, \"f\"):\n", " print(f\" X-ray f: {sm.Film.f}\")\n", - "if hasattr(sm.Film, 'b'):\n", + "if hasattr(sm.Film, \"b\"):\n", " print(f\" Neutron b: {sm.Film.b}\")\n", "\n", "print(\"\\n[Substrate Layer]\")\n", "print(f\" Roughness (σ): {sm.Sub.sigma:.4f} Å\")\n", - "if hasattr(sm.Sub, 'f'):\n", + "if hasattr(sm.Sub, \"f\"):\n", " print(f\" X-ray f: {sm.Sub.f}\")\n", "\n", "print(\"\\n[Ambient Layer]\")\n", - "if hasattr(sm.Amb, 'f'):\n", + "if hasattr(sm.Amb, \"f\"):\n", " print(f\" X-ray f: {sm.Amb.f}\")\n", "print(\"-\" * 30)\n", "\n", diff --git a/runs/exp04_one_genx/notebooks/ml_fitting.ipynb b/runs/exp04_one_genx/notebooks/ml_fitting.ipynb index a2ea53a..bc354be 100644 --- a/runs/exp04_one_genx/notebooks/ml_fitting.ipynb +++ b/runs/exp04_one_genx/notebooks/ml_fitting.ipynb @@ -28,7 +28,7 @@ "import os\n", "from pathlib import Path\n", "\n", - "if '__setup_done' not in globals():\n", + "if \"__setup_done\" not in globals():\n", " os.chdir(\"..\")\n", " __setup_done = True\n", " print(\"디렉토리 변경 완료\")\n", @@ -59,6 +59,7 @@ "if \"GLOBAL_MODEL_CACHE\" not in globals():\n", " GLOBAL_MODEL_CACHE = {}\n", "\n", + "\n", "def load_model_once():\n", " if \"model\" in GLOBAL_MODEL_CACHE:\n", " return GLOBAL_MODEL_CACHE[\"model\"], GLOBAL_MODEL_CACHE[\"dummy_dataset\"], GLOBAL_MODEL_CACHE[\"device\"]\n", @@ -92,9 +93,7 @@ " # -------------------------\n", " # 정규화 파라미터 로드\n", " # -------------------------\n", - " dummy_dataset = XRR1LayerDataset(h5_file, stats_file, mode=\"test\",\n", - " val_ratio=CONFIG[\"training\"][\"val_ratio\"])\n", - "\n", + " dummy_dataset = XRR1LayerDataset(h5_file, stats_file, mode=\"test\", val_ratio=CONFIG[\"training\"][\"val_ratio\"])\n", "\n", " # 캐시에 저장\n", " GLOBAL_MODEL_CACHE[\"model\"] = model\n", @@ -136,7 +135,7 @@ "from reflecto.simulate.simul_genx import ParamSet, XRRSimulator\n", "\n", "wavelen: float = 1.54 # (nm)\n", - "tth_min: float = 0.1 # degree\n", + "tth_min: float = 0.1 # degree\n", "tth_max: float = 6.0\n", "tth_n: int = 200\n", "tths: np.ndarray = np.linspace(tth_min, tth_max, tth_n)\n", @@ -173,14 +172,14 @@ "# -------------------------------------------------------------\n", "# 1️⃣ 실험 Reflectivity 정규화 → 모델 입력 준비\n", "# -------------------------------------------------------------\n", - "R_norm, R_max = dummy_dataset.normalize_reflectivity(true_refl) # (q_len,)\n", - "x = R_norm.unsqueeze(0).unsqueeze(0).to(device) # (1, 1, q_len)\n", + "R_norm, R_max = dummy_dataset.normalize_reflectivity(true_refl) # (q_len,)\n", + "x = R_norm.unsqueeze(0).unsqueeze(0).to(device) # (1, 1, q_len)\n", "\n", "# -------------------------------------------------------------\n", "# 2️⃣ 모델 추론 (정규화된 파라미터)\n", "# -------------------------------------------------------------\n", "with torch.no_grad():\n", - " y_pred_norm = model(x).squeeze().cpu().numpy() # (3,)\n", + " y_pred_norm = model(x).squeeze().cpu().numpy() # (3,)\n", "\n", "# -------------------------------------------------------------\n", "# 3️⃣ 역정규화 → 실제 물리 파라미터\n", @@ -223,8 +222,8 @@ "\n", "# 1) 측정 데이터 준비\n", "qz = qs\n", - "R = true_refl\n", - "dR = np.maximum(true_refl*0.3, 1e-8) # 0 에러 방지 권장\n", + "R = true_refl\n", + "dR = np.maximum(true_refl * 0.3, 1e-8) # 0 에러 방지 권장\n", "\n", "ds = DataSet(name=\"XRR 1-layer\")\n", "ds.x_raw = qz\n", @@ -320,14 +319,14 @@ "\n", "p_sld = pars.append(\"vars.set_sld\", model)\n", "p_sld.value = init_param.sld * 1e-5\n", - "p_sld.fit = False # 일단 끔\n", + "p_sld.fit = False # 일단 끔\n", "\n", "# [핵심] I0 파라미터 등록 및 켜기\n", "p_i0 = pars.append(\"vars.set_I0\", model)\n", - "p_i0.value = 1.0 # 초기값\n", - "p_i0.min = 0.1 # 범위 설정\n", + "p_i0.value = 1.0 # 초기값\n", + "p_i0.min = 0.1 # 범위 설정\n", "p_i0.max = 10.0\n", - "p_i0.fit = True # 이것만 켬\n", + "p_i0.fit = True # 이것만 켬\n", "\n", "model.parameters = pars\n", "\n", @@ -358,9 +357,9 @@ "# 다른 파라미터들 피팅 켜기\n", "p_thick.fit = True\n", "p_rough.fit = True\n", - "p_sld.fit = True\n", + "p_sld.fit = True\n", "# I0도 미세 조정을 위해 켜두거나, 고정하고 싶으면 False로 변경\n", - "p_i0.fit = False\n", + "p_i0.fit = False\n", "\n", "# 본격적인 피팅 (DE 또는 DREAM)\n", "res_final = model.bumps_fit(method=\"de\", steps=1000, pop=20, tol=0.01)\n", diff --git a/runs/exp04_one_genx/notebooks/ml_fitting_real.ipynb b/runs/exp04_one_genx/notebooks/ml_fitting_real.ipynb index 5ea48f2..aae2b84 100644 --- a/runs/exp04_one_genx/notebooks/ml_fitting_real.ipynb +++ b/runs/exp04_one_genx/notebooks/ml_fitting_real.ipynb @@ -49,6 +49,7 @@ "if \"GLOBAL_MODEL_CACHE\" not in globals():\n", " GLOBAL_MODEL_CACHE = {}\n", "\n", + "\n", "def load_model_once():\n", " if \"model\" in GLOBAL_MODEL_CACHE:\n", " return GLOBAL_MODEL_CACHE[\"model\"], GLOBAL_MODEL_CACHE[\"dummy_dataset\"], GLOBAL_MODEL_CACHE[\"device\"]\n", @@ -82,9 +83,7 @@ " # -------------------------\n", " # 정규화 파라미터 로드\n", " # -------------------------\n", - " dummy_dataset = XRR1LayerDataset(h5_file, stats_file, mode=\"test\",\n", - " val_ratio=CONFIG[\"training\"][\"val_ratio\"])\n", - "\n", + " dummy_dataset = XRR1LayerDataset(h5_file, stats_file, mode=\"test\", val_ratio=CONFIG[\"training\"][\"val_ratio\"])\n", "\n", " # 캐시에 저장\n", " GLOBAL_MODEL_CACHE[\"model\"] = model\n", @@ -163,14 +162,14 @@ "# -------------------------------------------------------------\n", "# 1️⃣ 실험 Reflectivity 정규화 → 모델 입력 준비\n", "# -------------------------------------------------------------\n", - "R_norm, R_max = dummy_dataset.normalize_reflectivity(true_refl) # (q_len,)\n", - "x = R_norm.unsqueeze(0).unsqueeze(0).to(device) # (1, 1, q_len)\n", + "R_norm, R_max = dummy_dataset.normalize_reflectivity(true_refl) # (q_len,)\n", + "x = R_norm.unsqueeze(0).unsqueeze(0).to(device) # (1, 1, q_len)\n", "\n", "# -------------------------------------------------------------\n", "# 2️⃣ 모델 추론 (정규화된 파라미터)\n", "# -------------------------------------------------------------\n", "with torch.no_grad():\n", - " y_pred_norm = model(x).squeeze().cpu().numpy() # (3,)\n", + " y_pred_norm = model(x).squeeze().cpu().numpy() # (3,)\n", "\n", "# -------------------------------------------------------------\n", "# 3️⃣ 역정규화 → 실제 물리 파라미터\n", @@ -212,8 +211,8 @@ "\n", "# 1) 측정 데이터 준비\n", "qz = tth2q(tths, wavelen)[true_refl > 0]\n", - "R = true_refl[true_refl > 0]\n", - "dR = np.maximum(R*0.3, 1e-8) # 0 에러 방지 권장\n", + "R = true_refl[true_refl > 0]\n", + "dR = np.maximum(R * 0.3, 1e-8) # 0 에러 방지 권장\n", "\n", "ds = DataSet(name=\"XRR 1-layer\")\n", "ds.x_raw = qz\n", @@ -309,14 +308,14 @@ "\n", "p_sld = pars.append(\"vars.set_sld\", model)\n", "p_sld.value = init_param.sld * 1e-5\n", - "p_sld.fit = False # 일단 끔\n", + "p_sld.fit = False # 일단 끔\n", "\n", "# [핵심] I0 파라미터 등록 및 켜기\n", "p_i0 = pars.append(\"vars.set_I0\", model)\n", - "p_i0.value = 1.0 # 초기값\n", - "p_i0.min = 0.1 # 범위 설정\n", + "p_i0.value = 1.0 # 초기값\n", + "p_i0.min = 0.1 # 범위 설정\n", "p_i0.max = 10.0\n", - "p_i0.fit = True # 이것만 켬\n", + "p_i0.fit = True # 이것만 켬\n", "\n", "model.parameters = pars\n", "\n", @@ -347,9 +346,9 @@ "# 다른 파라미터들 피팅 켜기\n", "p_thick.fit = True\n", "p_rough.fit = True\n", - "p_sld.fit = True\n", + "p_sld.fit = True\n", "# I0도 미세 조정을 위해 켜두거나, 고정하고 싶으면 False로 변경\n", - "p_i0.fit = False\n", + "p_i0.fit = False\n", "\n", "# 본격적인 피팅 (DE 또는 DREAM)\n", "res_final = model.bumps_fit(method=\"de\", steps=1000, pop=20, tol=0.01)\n", diff --git a/runs/exp04_one_genx/simulate.py b/runs/exp04_one_genx/simulate.py index 267e6a7..71686f0 100644 --- a/runs/exp04_one_genx/simulate.py +++ b/runs/exp04_one_genx/simulate.py @@ -28,22 +28,14 @@ def generate_1layer_data(config: dict, h5_file: Path | str): q_max = tth2q(simulation["tth_max"], simulation["wavelength"]) qs = np.linspace(q_min, q_max, simulation["q_points"]) - simulator_args: dict = { - "qs": qs, - "n_layers": 1, - "n_samples": simulation["n_samples"], - "has_noise": True - } + simulator_args: dict = {"qs": qs, "n_layers": 1, "n_samples": simulation["n_samples"], "has_noise": True} if param_ranges["thickness"] is not None: simulator_args["thickness_range"] = param_ranges["thickness"] if param_ranges["roughness"] is not None: simulator_args["roughness_range"] = param_ranges["roughness"] if param_ranges["sld"] is not None: simulator_args["sld_range"] = param_ranges["sld"] - simulator = XRRSimulator( - **simulator_args - ) - + simulator = XRRSimulator(**simulator_args) simulator.save_hdf5(h5_file, show_progress=True) @@ -52,5 +44,6 @@ def generate_1layer_data(config: dict, h5_file: Path | str): print(f" - q 포인트: {len(qs)}") print(f" - 파라미터 범위: {param_ranges}") + if __name__ == "__main__": pass diff --git a/runs/exp04_one_genx/train.py b/runs/exp04_one_genx/train.py index 0792289..a94d84f 100644 --- a/runs/exp04_one_genx/train.py +++ b/runs/exp04_one_genx/train.py @@ -9,15 +9,13 @@ class Trainer: - def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, - save_dir: str | Path, - lr: float = 1e-3, weight_decay: float = 1e-5): + def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, lr: float = 1e-3, weight_decay: float = 1e-5): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = model.to(self.device) self.optimizer = optim.AdamW(self.model.parameters(), lr=lr, weight_decay=weight_decay) - self.scheduler = ReduceLROnPlateau(self.optimizer, mode='min', patience=7, factor=0.5) + self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=7, factor=0.5) self.train_loader = train_loader self.val_loader = val_loader @@ -25,7 +23,7 @@ def __init__(self, model, train_loader: DataLoader, val_loader: DataLoader, self.checkpoint_dir = Path(save_dir) self.checkpoint_dir.mkdir(exist_ok=True) - self.best_val_loss = float('inf') + self.best_val_loss = float("inf") self.patience_counter = 0 self.early_stop_patience = 15 @@ -48,7 +46,7 @@ def train_epoch(self, epoch: int) -> float: self.optimizer.step() losses.append(loss.item()) - pbar.set_postfix({'loss': f'{loss.item():.4f}'}) + pbar.set_postfix({"loss": f"{loss.item():.4f}"}) return np.mean(losses) @@ -60,7 +58,7 @@ def validate(self) -> float: for R, params in self.val_loader: R, params = R.to(self.device), params.to(self.device) pred = self.model(R) - total_loss += torch.nn.functional.mse_loss(pred, params, reduction='sum').item() + total_loss += torch.nn.functional.mse_loss(pred, params, reduction="sum").item() return total_loss / len(self.val_loader.dataset) @@ -88,8 +86,7 @@ def train(self, epochs: int, resume_from: str | Path | None = None): train_loss = self.train_epoch(epoch) val_loss = self.validate() - print(f"【Epoch {epoch:02d}】 Train Loss: {train_loss:.4f} | " - f"Val Loss: {val_loss:.4f} | LR: {self.optimizer.param_groups[0]['lr']:.6f}") + print(f"【Epoch {epoch:02d}】 Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | LR: {self.optimizer.param_groups[0]['lr']:.6f}") self.scheduler.step(val_loss) @@ -97,7 +94,7 @@ def train(self, epochs: int, resume_from: str | Path | None = None): if val_loss < self.best_val_loss: self.best_val_loss = val_loss self.patience_counter = 0 - self.save_checkpoint('best.pt', epoch, val_loss) + self.save_checkpoint("best.pt", epoch, val_loss) print("모델 저장 (best)") else: self.patience_counter += 1 @@ -109,17 +106,20 @@ def train(self, epochs: int, resume_from: str | Path | None = None): def save_checkpoint(self, filename: str, epoch: int, val_loss: float): """체크포인트 저장 (학습 상태 포함)""" - torch.save({ - 'epoch': epoch, - 'model_state_dict': self.model.state_dict(), - 'optimizer_state_dict': self.optimizer.state_dict(), - 'scheduler_state_dict': self.scheduler.state_dict(), - 'val_loss': val_loss, - 'config': { - 'model_class': self.model.__class__.__name__, - 'model_args': self.model.config, - } - }, self.checkpoint_dir / filename) + torch.save( + { + "epoch": epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "scheduler_state_dict": self.scheduler.state_dict(), + "val_loss": val_loss, + "config": { + "model_class": self.model.__class__.__name__, + "model_args": self.model.config, + }, + }, + self.checkpoint_dir / filename, + ) print(f"Saved checkpoint: {self.checkpoint_dir / filename}") def load_checkpoint(self, filepath: str | Path) -> int: @@ -127,18 +127,18 @@ def load_checkpoint(self, filepath: str | Path) -> int: checkpoint = torch.load(filepath, map_location=self.device) # 모델 상태 로드 - self.model.load_state_dict(checkpoint['model_state_dict']) + 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 "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']) + if "scheduler_state_dict" in checkpoint: + self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"]) - self.best_val_loss = checkpoint.get('val_loss', float('inf')) + self.best_val_loss = checkpoint.get("val_loss", float("inf")) - loaded_epoch = checkpoint.get('epoch', 0) + loaded_epoch = checkpoint.get("epoch", 0) print(f"✅ 체크포인트 로드 완료: {filepath} (에포크 {loaded_epoch})") return loaded_epoch diff --git a/runs/exp05_1layer_mask/augmentations.py b/runs/exp05_1layer_mask/augmentations.py index 4576316..a488367 100644 --- a/runs/exp05_1layer_mask/augmentations.py +++ b/runs/exp05_1layer_mask/augmentations.py @@ -7,12 +7,13 @@ class XRRAugmentations: """ Applies physics-based data augmentation to XRR signals. """ + def __init__( self, intensity_noise_scale: float = 0.1, # +/- 10% scaling - q_shift_sigma: float = 0.002, # Standard deviation for q-shift (1/A) - background_level: float = 1e-7, # Random background noise floor - prob: float = 0.5 # Probability of applying augmentation + q_shift_sigma: float = 0.002, # Standard deviation for q-shift (1/A) + background_level: float = 1e-7, # Random background noise floor + prob: float = 0.5, # Probability of applying augmentation ): self.intensity_scale = intensity_noise_scale self.q_shift_sigma = q_shift_sigma diff --git a/runs/exp05_1layer_mask/config.py b/runs/exp05_1layer_mask/config.py index d6dc5d7..db2023e 100644 --- a/runs/exp05_1layer_mask/config.py +++ b/runs/exp05_1layer_mask/config.py @@ -17,14 +17,7 @@ "sio2_roughness": None, "sio2_sld": None, }, - "simulation": { - "wavelength": 1.54, - "n_samples": int(1e6), - "q_points": 1000, - "q_min": tth2q(0.1), - "q_max": tth2q(15), - "power": 1 - }, + "simulation": {"wavelength": 1.54, "n_samples": int(1e6), "q_points": 1000, "q_min": tth2q(0.1), "q_max": tth2q(15), "power": 1}, "model": { "n_channels": 32, "depth": 5, @@ -38,16 +31,17 @@ "weight_decay": 0.0005310131070268705, "val_ratio": 0.2, "test_ratio": 0.1, - "patience" : 15, + "patience": 15, "num_workers": 0, "augment": True, "aug_prob": 0.5, - "q_shift_sigma": 0.002, # +/- 0.002 A^-1 shift + "q_shift_sigma": 0.002, # +/- 0.002 A^-1 shift "intensity_scale": 0.15, # +/- 15% intensity variation "expand_factor": 10, }, } + def save_config(config: dict, file: Path | str): """ 딕셔너리를 JSON 파일로 저장합니다. diff --git a/runs/exp05_1layer_mask/dataset.py b/runs/exp05_1layer_mask/dataset.py index b7c229d..9c41c2e 100644 --- a/runs/exp05_1layer_mask/dataset.py +++ b/runs/exp05_1layer_mask/dataset.py @@ -12,11 +12,8 @@ class XRRPreprocessor: Shared class for XRR data preprocessing and inverse transformation. (Used by both Dataset and InferenceEngine) """ - def __init__(self, - qs: np.ndarray, - stats_file: Path | str | None = None, - device: torch.device = torch.device('cpu') - ): + + 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 @@ -43,7 +40,7 @@ def process_input(self, q_raw, R_raw): # 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 # 0 나누기 방지 R_norm = R_raw / R_max R_log = np.log10(np.maximum(R_norm, 1e-15)) @@ -105,10 +102,19 @@ class XRR1LayerDataset(Dataset): """ def __init__( - self, qs: np.ndarray, h5_file: str | Path, stats_file: str | Path, - mode: str = "train", val_ratio: float = 0.2, test_ratio: float = 0.1, - augment: bool = False, expand_factor: int = 1, aug_prob: float = 0.5, - min_scan_range: float = 0.15, q_shift_sigma: float = 0.002, intensity_scale: float = 0.1 + self, + qs: np.ndarray, + h5_file: str | Path, + stats_file: str | Path, + mode: str = "train", + val_ratio: float = 0.2, + test_ratio: float = 0.1, + augment: bool = False, + expand_factor: int = 1, + aug_prob: float = 0.5, + min_scan_range: float = 0.15, + q_shift_sigma: float = 0.002, + intensity_scale: float = 0.1, ): self.h5_path = Path(h5_file) @@ -117,16 +123,12 @@ def __init__( # Grid & Augmentation self.target_q = qs - self.augment = augment and (mode == 'train') - self.expand_factor = expand_factor if (mode == 'train') else 1 + self.augment = augment and (mode == "train") + self.expand_factor = expand_factor if (mode == "train") else 1 self.min_scan_range = min_scan_range self.aug_prob = aug_prob if self.augment: - self.physics_augmenter = XRRAugmentations( - intensity_noise_scale=intensity_scale, - q_shift_sigma=q_shift_sigma, - prob=aug_prob - ) + self.physics_augmenter = XRRAugmentations(intensity_noise_scale=intensity_scale, q_shift_sigma=q_shift_sigma, prob=aug_prob) self.hf: h5py.File | None = None # Load Data @@ -138,7 +140,7 @@ def __init__( # Setup Normalization Statistics self.processor = XRRPreprocessor(self.target_q) - if mode == 'train' and not self.stats_path.exists(): + if mode == "train" and not self.stats_path.exists(): self._calculate_and_save_stats() # self._setup_param_stats() @@ -160,20 +162,15 @@ def _load_metadata_only(self): def _calculate_and_save_stats(self): """Calculate normalization stats on training subset.""" print(f"[{self.mode}] Calculating statistics...") - idx = range(self.train_end) # Use only training part + idx = range(self.train_end) # Use only training part - params_list = [ - self.thickness[idx], self.roughness[idx], self.sld[idx] - ] + params_list = [self.thickness[idx], self.roughness[idx], self.sld[idx]] params = np.stack(params_list, axis=1) mean = np.mean(params, axis=0) std = np.std(params, axis=0) + 1e-8 - torch.save({ - "param_mean": torch.from_numpy(mean), - "param_std": torch.from_numpy(std) - }, self.stats_path) + torch.save({"param_mean": torch.from_numpy(mean), "param_std": torch.from_numpy(std)}, self.stats_path) def _setup_split(self, val_ratio, test_ratio): """Split indices for Train/Val/Test""" @@ -215,7 +212,7 @@ def __getitem__(self, idx): def _get_raw_data_lazy(self, idx): if self.hf is None: - self.hf = h5py.File(self.h5_path, 'r', swmr=True) + self.hf = h5py.File(self.h5_path, "r", swmr=True) R_raw = self.hf["R"][idx] q_raw = self.source_q[idx] if self.source_q.ndim == 2 else self.source_q diff --git a/runs/exp05_1layer_mask/evaluate.py b/runs/exp05_1layer_mask/evaluate.py index 1e53cea..2d65bc3 100644 --- a/runs/exp05_1layer_mask/evaluate.py +++ b/runs/exp05_1layer_mask/evaluate.py @@ -20,16 +20,19 @@ # ------------------------------------------------------------ # 0. Global Style Settings # ------------------------------------------------------------ -plt.style.use('seaborn-v0_8-paper') -plt.rcParams.update({ - 'font.family': 'serif', - 'axes.grid': True, - 'grid.alpha': 0.3, - 'lines.linewidth': 1.5, - 'axes.titlesize': 14, - 'axes.labelsize': 12, - 'figure.dpi': 150 -}) +plt.style.use("seaborn-v0_8-paper") +plt.rcParams.update( + { + "font.family": "serif", + "axes.grid": True, + "grid.alpha": 0.3, + "lines.linewidth": 1.5, + "axes.titlesize": 14, + "axes.labelsize": 12, + "figure.dpi": 150, + } +) + # ------------------------------------------------------------ # 1. Calculation Logic (Metrics & FOM) @@ -42,7 +45,7 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: abs_errors = np.abs(errors) mae = np.mean(abs_errors, axis=0) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) eps = 1e-6 mape = np.mean(abs_errors / (np.abs(targets) + eps), axis=0) * 100 @@ -54,6 +57,7 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: "mape": mape, } + def fom_log(ref_exp, ref_calc, epsilon=1e-10): """ Figure of Merit (Log-scale difference) 계산 @@ -63,6 +67,7 @@ def fom_log(ref_exp, ref_calc, epsilon=1e-10): ref_calc = np.maximum(ref_calc, epsilon) return np.mean(np.abs(np.log10(ref_exp) - np.log10(ref_calc))) + def calculate_all_foms(qs, preds, targets): """ AI 예측 파라미터로 물리 시뮬레이션을 돌려 실제 커브와 비교 (FOM 계산) @@ -84,10 +89,11 @@ def calculate_all_foms(qs, preds, targets): return np.array(foms) + def generate_results_df(preds, targets, errors, foms, param_names): data = {} for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] data[f"{clean}_True"] = targets[:, i] data[f"{clean}_Pred"] = preds[:, i] data[f"{clean}_Error"] = errors[:, i] @@ -98,6 +104,7 @@ def generate_results_df(preds, targets, errors, foms, param_names): return pd.DataFrame(data) + # ------------------------------------------------------------ # 2. Plotting Functions # ------------------------------------------------------------ @@ -105,12 +112,12 @@ def save_history_plot(history: dict, save_path: Path) -> None: if not save_path or not history: return - epochs = range(1, len(history['train']) + 1) + epochs = range(1, len(history["train"]) + 1) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) # Loss Plot - ax1.plot(epochs, history['train'], label="Train Loss", lw=2) - ax1.plot(epochs, history['val'], label="Val Loss", lw=2) + ax1.plot(epochs, history["train"], label="Train Loss", lw=2) + ax1.plot(epochs, history["val"], label="Val Loss", lw=2) ax1.set_title("Training & Validation Loss") ax1.set_xlabel("Epochs") ax1.set_ylabel("Loss") @@ -119,7 +126,7 @@ def save_history_plot(history: dict, save_path: Path) -> None: # LR Plot if "lr" in history: - ax2.plot(epochs, history['lr'], label="Learning Rate", color='orange', linestyle="--") + ax2.plot(epochs, history["lr"], label="Learning Rate", color="orange", linestyle="--") ax2.set_yscale("log") ax2.set_title("Learning Rate Schedule") ax2.set_xlabel("Epochs") @@ -132,17 +139,18 @@ def save_history_plot(history: dict, save_path: Path) -> None: plt.close() print(f"Saved History Plot: {save_path}") + def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_path: Path): if not save_path: return n_params = len(param_names) - fig = plt.figure(figsize=(18, 12), layout='constrained') + fig = plt.figure(figsize=(18, 12), layout="constrained") gs = gridspec.GridSpec(3, n_params, figure=fig) - colors = ['#1f77b4', '#ff7f0e', '#2ca02c'] + colors = ["#1f77b4", "#ff7f0e", "#2ca02c"] for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] y_true = df[f"{clean}_True"].values y_pred = df[f"{clean}_Pred"].values err = df[f"{clean}_Error"].values @@ -151,45 +159,45 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa 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()) - ax1.hexbin(y_true, y_pred, gridsize=40, cmap='Blues', mincnt=1, edgecolors='none') - ax1.plot([min_v, max_v], [min_v, max_v], 'r--', lw=2, alpha=0.8) + ax1.hexbin(y_true, y_pred, gridsize=40, cmap="Blues", mincnt=1, edgecolors="none") + ax1.plot([min_v, max_v], [min_v, max_v], "r--", lw=2, alpha=0.8) corr = np.corrcoef(y_true, y_pred)[0, 1] - 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') + 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='--') + 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="--") std_err = np.std(err) - ax2.axhline(1.96 * std_err, color='red', linestyle=':', alpha=0.6) - ax2.axhline(-1.96 * std_err, color='red', linestyle=':', alpha=0.6) - ax2.set_title(f"{clean}: Residuals", fontweight='bold') + ax2.axhline(1.96 * std_err, color="red", linestyle=":", alpha=0.6) + 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. * np.arange(len(err)) / (len(err) - 1) - ax3.plot(sorted_abs_err, p, lw=3, color='darkslategray') + p = 1.0 * np.arange(len(err)) / (len(err) - 1) + ax3.plot(sorted_abs_err, p, lw=3, color="darkslategray") idx_90 = int(len(err) * 0.9) val_90 = sorted_abs_err[idx_90] - ax3.axvline(val_90, color='firebrick', linestyle='--') - ax3.text(val_90, 0.5, f' 90% < {val_90:.2f}', color='firebrick', fontweight='bold') - ax3.set_title(f"{clean}: Error CDF", fontweight='bold') + ax3.axvline(val_90, color="firebrick", linestyle="--") + ax3.text(val_90, 0.5, f" 90% < {val_90:.2f}", color="firebrick", fontweight="bold") + ax3.set_title(f"{clean}: Error CDF", fontweight="bold") ax3.set_xlabel("Absolute Error") - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved Metric Report: {save_path}") + def save_fom_report(foms: np.ndarray, save_path: Path): if not save_path or foms is None: return - fig = plt.figure(figsize=(10, 6), layout='constrained') + fig = plt.figure(figsize=(10, 6), layout="constrained") gs = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1.5, 1]) # 1. Histogram @@ -197,20 +205,20 @@ def save_fom_report(foms: np.ndarray, save_path: Path): cut_off = np.percentile(foms, 99) foms_view = foms[foms <= cut_off] - ax1.hist(foms_view, bins=30, color='royalblue', alpha=0.7, density=True, label='Density') - ax1.set_title("FOM Distribution (Log Scale Error)", fontweight='bold') + ax1.hist(foms_view, bins=30, color="royalblue", alpha=0.7, density=True, label="Density") + ax1.set_title("FOM Distribution (Log Scale Error)", fontweight="bold") 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.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. Stats Text ax2 = fig.add_subplot(gs[1]) - ax2.axis('off') + ax2.axis("off") stats_text = ( f"DATASET SUMMARY\n" @@ -227,14 +235,22 @@ def save_fom_report(foms: np.ndarray, save_path: Path): f" - Top 99% < {np.percentile(foms, 99):.4f}" ) - ax2.text(0.1, 0.5, stats_text, fontsize=12, family='monospace', - verticalalignment='center', transform=ax2.transAxes, - bbox={'boxstyle': 'round,pad=1', 'facecolor': '#f8f9fa', 'edgecolor': '#dee2e6'}) + ax2.text( + 0.1, + 0.5, + stats_text, + fontsize=12, + family="monospace", + verticalalignment="center", + transform=ax2.transAxes, + bbox={"boxstyle": "round,pad=1", "facecolor": "#f8f9fa", "edgecolor": "#dee2e6"}, + ) - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved FOM Report: {save_path}") + def save_curve_comparison(qs, preds, targets, foms, save_path: Path): if not save_path or foms is None: return @@ -243,15 +259,11 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): 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:] - ]) + 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"] - fig, axes = plt.subplots(2, 3, figsize=(18, 10), layout='constrained') + fig, axes = plt.subplots(2, 3, figsize=(18, 10), layout="constrained") axes = axes.flatten() for i, idx in enumerate(indices): @@ -267,29 +279,30 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): curve_true = param2refl(qs, [p_true]).flatten() curve_pred = param2refl(qs, [p_pred]).flatten() - 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') + 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") - thick_err = abs(t_true[0]-t_pred[0]) + thick_err = abs(t_true[0] - t_pred[0]) title_str = f"{titles[i]}\nFOM: {fom_val:.5f} | Thick Err: {thick_err:.1f}Å" - color = 'darkgreen' if fom_val < 0.05 else 'black' + color = "darkgreen" if fom_val < 0.05 else "black" if i >= 4: - color = 'firebrick' # Worst cases + color = "firebrick" # Worst cases - ax.set_title(title_str, fontsize=11, fontweight='bold', color=color) + ax.set_title(title_str, fontsize=11, fontweight="bold", color=color) ax.set_xlabel("Q ($Å^{-1}$)") if i % 3 == 0: ax.set_ylabel("Reflectivity (log)") if i == 0: ax.legend(frameon=True) - ax.grid(True, which='both', alpha=0.3) + ax.grid(True, which="both", alpha=0.3) - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved Curve Comparison: {save_path}") + # ------------------------------------------------------------ # 3. Main Evaluation Pipeline # ------------------------------------------------------------ @@ -299,7 +312,7 @@ def evaluate_pipeline( stats_path: Path, qs: np.ndarray, output_dir: Path, - calc_physics_fom: bool = False # [New Argument] + calc_physics_fom: bool = False, # [New Argument] ): if not checkpoint_path.exists() or not stats_path.exists(): @@ -358,10 +371,7 @@ def evaluate_pipeline( print(f"{'Parameter':<20} | {'MAE':<10} | {'RMSE':<10} | {'MAPE (%)':<10}") print("-" * 65) for i, name in enumerate(param_names): - print(f"{name:<20} | " - f"{metrics['mae'][i]:<10.4f} | " - f"{metrics['rmse'][i]:<10.4f} | " - f"{metrics['mape'][i]:<10.2f}") + print(f"{name:<20} | {metrics['mae'][i]:<10.4f} | {metrics['rmse'][i]:<10.4f} | {metrics['mape'][i]:<10.2f}") if foms is not None: print("-" * 65) @@ -396,7 +406,7 @@ def evaluate_pipeline( 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!)') + parser.add_argument("--fom", action="store_true", help="Calculate physics-based FOM (Slow!)") args = parser.parse_args() base = Path(CONFIG["base_dir"]) @@ -414,29 +424,12 @@ def main(): return # Q-space generation for Physics Simulation - qs = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"] - ) + qs = powerspace(CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"]) test_set = XRR1LayerDataset(qs, h5_file, stats_file, mode="test") - loader = DataLoader( - test_set, - batch_size=CONFIG["training"]["batch_size"], - shuffle=False, - num_workers=0 - ) + loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) - evaluate_pipeline( - test_loader=loader, - checkpoint_path=ckpt_file, - stats_path=stats_file, - qs=qs, - output_dir=exp_dir, - calc_physics_fom=args.fom - ) + evaluate_pipeline(test_loader=loader, checkpoint_path=ckpt_file, stats_path=stats_file, qs=qs, output_dir=exp_dir, calc_physics_fom=args.fom) if __name__ == "__main__": diff --git a/runs/exp05_1layer_mask/evaluate2.py b/runs/exp05_1layer_mask/evaluate2.py index eaff98f..98092ca 100644 --- a/runs/exp05_1layer_mask/evaluate2.py +++ b/runs/exp05_1layer_mask/evaluate2.py @@ -17,16 +17,19 @@ from reflecto_exp.simulate.simul_genx import ParamSet, param2refl # 스타일 설정 -plt.style.use('seaborn-v0_8-paper') -plt.rcParams.update({ - 'font.family': 'serif', - 'axes.grid': True, - 'grid.alpha': 0.3, - 'lines.linewidth': 1.5, - 'axes.titlesize': 14, - 'axes.labelsize': 12, - 'figure.dpi': 150 -}) +plt.style.use("seaborn-v0_8-paper") +plt.rcParams.update( + { + "font.family": "serif", + "axes.grid": True, + "grid.alpha": 0.3, + "lines.linewidth": 1.5, + "axes.titlesize": 14, + "axes.labelsize": 12, + "figure.dpi": 150, + } +) + # ------------------------------------------------------------ # 0. FOM Calculation Logic @@ -41,6 +44,7 @@ def fom_log(ref_exp, ref_calc, epsilon=1e-10): ref_calc = np.maximum(ref_calc, epsilon) return np.mean(np.abs(np.log10(ref_exp) - np.log10(ref_calc))) + def calculate_all_foms(qs, preds, targets): """ 전체 데이터셋에 대해 Curve를 생성하고 FOM을 계산합니다. @@ -62,6 +66,7 @@ def calculate_all_foms(qs, preds, targets): return np.array(foms) + # ------------------------------------------------------------ # 1. Metrics & Data Utils (Existing) # ------------------------------------------------------------ @@ -69,15 +74,16 @@ def calculate_metrics(preds, targets): errors = preds - targets abs_errors = np.abs(errors) mae = np.mean(abs_errors, axis=0) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) eps = 1e-6 mape = np.mean(abs_errors / (np.abs(targets) + eps), axis=0) * 100 return {"errors": errors, "mae": mae, "rmse": rmse, "mape": mape} + def generate_results_df(preds, targets, errors, foms, param_names): data = {} for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] data[f"{clean}_True"] = targets[:, i] data[f"{clean}_Pred"] = preds[:, i] data[f"{clean}_Error"] = errors[:, i] @@ -86,6 +92,7 @@ def generate_results_df(preds, targets, errors, foms, param_names): data["FOM_Log"] = foms return pd.DataFrame(data) + # ------------------------------------------------------------ # 2. FOM Distribution Plot (New!) # ------------------------------------------------------------ @@ -96,7 +103,7 @@ def save_fom_report(foms: np.ndarray, save_path: Path): if not save_path: return - fig = plt.figure(figsize=(10, 6), layout='constrained') + fig = plt.figure(figsize=(10, 6), layout="constrained") gs = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1.5, 1]) # 1. Histogram & KDE @@ -105,21 +112,21 @@ def save_fom_report(foms: np.ndarray, save_path: Path): cut_off = np.percentile(foms, 99) foms_view = foms[foms <= cut_off] - ax1.hist(foms_view, bins=30, color='royalblue', alpha=0.7, density=True, label='Density') - ax1.set_title("FOM Distribution (Log Scale Error)", fontweight='bold') + ax1.hist(foms_view, bins=30, color="royalblue", alpha=0.7, density=True, label="Density") + ax1.set_title("FOM Distribution (Log Scale Error)", fontweight="bold") 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.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') + ax2.axis("off") stats_text = ( f"DATASET SUMMARY\n" @@ -136,14 +143,22 @@ def save_fom_report(foms: np.ndarray, save_path: Path): f" - Top 99% < {np.percentile(foms, 99):.4f}" ) - ax2.text(0.1, 0.5, stats_text, fontsize=13, family='monospace', - verticalalignment='center', transform=ax2.transAxes, - bbox={'boxstyle': 'round,pad=1', 'facecolor': '#f8f9fa', 'edgecolor': '#dee2e6'}) + ax2.text( + 0.1, + 0.5, + stats_text, + fontsize=13, + family="monospace", + verticalalignment="center", + transform=ax2.transAxes, + bbox={"boxstyle": "round,pad=1", "facecolor": "#f8f9fa", "edgecolor": "#dee2e6"}, + ) - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved FOM Report: {save_path}") + # ------------------------------------------------------------ # 3. Curve Reconstruction Plot (Updated with FOM) # ------------------------------------------------------------ @@ -154,15 +169,17 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): # FOM 기준으로 Best/Worst 정렬 (MSE 대신 FOM 사용이 더 설득력 있음) sorted_indices = np.argsort(foms) - indices = np.concatenate([ - sorted_indices[:2], # Best FOM - np.random.choice(sorted_indices[2:-2], 2, replace=False), # Random - sorted_indices[-2:] # Worst FOM - ]) + indices = np.concatenate( + [ + sorted_indices[:2], # Best FOM + np.random.choice(sorted_indices[2:-2], 2, replace=False), # Random + sorted_indices[-2:], # Worst FOM + ] + ) titles = ["Best Case 1", "Best Case 2", "Random Case 1", "Random Case 2", "Worst Case 1", "Worst Case 2"] - fig, axes = plt.subplots(2, 3, figsize=(18, 10), layout='constrained') + fig, axes = plt.subplots(2, 3, figsize=(18, 10), layout="constrained") axes = axes.flatten() for i, idx in enumerate(indices): @@ -170,7 +187,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] # 해당 샘플의 FOM p_true = ParamSet(t_true[0], t_true[1], t_true[2]) p_pred = ParamSet(t_pred[0], t_pred[1], t_pred[2]) @@ -178,30 +195,31 @@ def save_curve_comparison(qs, preds, targets, foms, save_path: Path): curve_true = param2refl(qs, [p_true]).flatten() curve_pred = param2refl(qs, [p_pred]).flatten() - 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') + 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}Å" + 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' + color = "darkgreen" if fom_val < 0.05 else "black" if i >= 4: - color = 'firebrick' # Worst cases + color = "firebrick" # Worst cases - ax.set_title(title_str, fontsize=11, fontweight='bold', color=color) + ax.set_title(title_str, fontsize=11, fontweight="bold", color=color) ax.set_xlabel("Q ($Å^{-1}$)") if i % 3 == 0: ax.set_ylabel("Reflectivity (log)") if i == 0: ax.legend(frameon=True) - ax.grid(True, which='both', alpha=0.3) + ax.grid(True, which="both", alpha=0.3) - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved Curve Proof: {save_path}") + # ------------------------------------------------------------ # 4. Investor Report (Metrics - Same as before) # ------------------------------------------------------------ @@ -209,12 +227,12 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa if not save_path: return n_params = len(param_names) - fig = plt.figure(figsize=(18, 12), layout='constrained') + fig = plt.figure(figsize=(18, 12), layout="constrained") gs = gridspec.GridSpec(3, n_params, figure=fig) - colors = ['#1f77b4', '#ff7f0e', '#2ca02c'] + colors = ["#1f77b4", "#ff7f0e", "#2ca02c"] for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] y_true = df[f"{clean}_True"].values y_pred = df[f"{clean}_Pred"].values err = df[f"{clean}_Error"].values @@ -223,36 +241,37 @@ def save_investor_report_plots(df: pd.DataFrame, param_names: list[str], save_pa 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()) - ax1.hexbin(y_true, y_pred, gridsize=40, cmap='Blues', mincnt=1, edgecolors='none') - ax1.plot([min_v, max_v], [min_v, max_v], 'r--', lw=2, alpha=0.8) + ax1.hexbin(y_true, y_pred, gridsize=40, cmap="Blues", mincnt=1, edgecolors="none") + ax1.plot([min_v, max_v], [min_v, max_v], "r--", lw=2, alpha=0.8) corr = np.corrcoef(y_true, y_pred)[0, 1] - 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') + 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") # Residuals 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='--') + 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="--") std_err = np.std(err) - ax2.axhline(1.96 * std_err, color='red', linestyle=':', alpha=0.6) - ax2.axhline(-1.96 * std_err, color='red', linestyle=':', alpha=0.6) - ax2.set_title(f"{clean}: Residuals", fontweight='bold') + ax2.axhline(1.96 * std_err, color="red", linestyle=":", alpha=0.6) + ax2.axhline(-1.96 * std_err, color="red", linestyle=":", alpha=0.6) + ax2.set_title(f"{clean}: Residuals", fontweight="bold") # CDF ax3 = fig.add_subplot(gs[2, i]) sorted_abs_err = np.sort(np.abs(err)) - p = 1. * np.arange(len(err)) / (len(err) - 1) - ax3.plot(sorted_abs_err, p, lw=3, color='darkslategray') + p = 1.0 * np.arange(len(err)) / (len(err) - 1) + ax3.plot(sorted_abs_err, p, lw=3, color="darkslategray") idx_90 = int(len(err) * 0.9) val_90 = sorted_abs_err[idx_90] - ax3.axvline(val_90, color='firebrick', linestyle='--') - ax3.text(val_90, 0.5, f' 90% < {val_90:.2f}', color='firebrick', fontweight='bold') - ax3.set_title(f"{clean}: Error CDF", fontweight='bold') + ax3.axvline(val_90, color="firebrick", linestyle="--") + ax3.text(val_90, 0.5, f" 90% < {val_90:.2f}", color="firebrick", fontweight="bold") + ax3.set_title(f"{clean}: Error CDF", fontweight="bold") - plt.savefig(save_path, bbox_inches='tight') + plt.savefig(save_path, bbox_inches="tight") plt.close() print(f"Saved Metric Report: {save_path}") + # ------------------------------------------------------------ # 5. Pipeline # ------------------------------------------------------------ @@ -313,12 +332,13 @@ def evaluate_pipeline(test_loader, checkpoint_path, stats_path, qs, output_dir): save_curve_comparison(qs, preds_real, targets_real, foms, output_dir / "investor_curve_proof.png") # Console Output - print("\n" + "="*60) + print("\n" + "=" * 60) print(f"{'Metric':<20} | {'Value':<10}") print("-" * 60) print(f"{'Mean FOM':<20} | {np.mean(foms):.5f}") print(f"{'Median FOM':<20} | {np.median(foms):.5f}") - print("="*60) + print("=" * 60) + # ------------------------------------------------------------ # 6. Main @@ -327,12 +347,7 @@ def main(): base = Path(CONFIG["base_dir"]) exp_dir = base / CONFIG["exp_name"] - qs = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"] - ) + qs = powerspace(CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"]) h5_file = exp_dir / "dataset.h5" stats_file = exp_dir / "stats.pt" @@ -343,19 +358,10 @@ def main(): return test_set = XRR1LayerDataset(qs, h5_file, stats_file, mode="test") - loader = DataLoader( - test_set, - batch_size=CONFIG["training"]["batch_size"], - shuffle=False - ) + loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False) + + evaluate_pipeline(test_loader=loader, checkpoint_path=ckpt_file, stats_path=stats_file, qs=qs, output_dir=exp_dir) - evaluate_pipeline( - test_loader=loader, - checkpoint_path=ckpt_file, - stats_path=stats_file, - qs=qs, - output_dir=exp_dir - ) if __name__ == "__main__": main() diff --git a/runs/exp05_1layer_mask/fitting_engine.py b/runs/exp05_1layer_mask/fitting_engine.py index c580e0a..08d7602 100644 --- a/runs/exp05_1layer_mask/fitting_engine.py +++ b/runs/exp05_1layer_mask/fitting_engine.py @@ -130,27 +130,27 @@ def run(self, verbose=True): # --- 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 + p_f_d.min = 1.0 + p_f_d.max = self.init_d * 2 p_f_sig = pars.append("v.set_f_sig", model) - p_f_sig.min=0.0 - p_f_sig.max=30.0 + p_f_sig.min = 0.0 + p_f_sig.max = 30.0 p_f_sld = pars.append("v.set_f_sld", model) - p_f_sld.min=0.1 - p_f_sld.max=50.0 + p_f_sld.min = 0.1 + p_f_sld.max = 50.0 p_s_d = pars.append("v.set_s_d", model) - p_s_d.value=15.0 - p_s_d.min=5.0 - p_s_d.max=50.0 + p_s_d.value = 15.0 + p_s_d.min = 5.0 + p_s_d.max = 50.0 p_s_sig = pars.append("v.set_s_sig", model) - p_s_sig.value=3.0 - p_s_sig.min=0.0 - p_s_sig.max=15.0 + p_s_sig.value = 3.0 + p_s_sig.min = 0.0 + p_s_sig.max = 15.0 p_s_sld = pars.append("v.set_s_sld", model) - p_s_sld.value=18.8 - p_s_sld.min=10.0 - p_s_sld.max=23.0 + p_s_sld.value = 18.8 + 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) @@ -161,22 +161,22 @@ def run(self, verbose=True): # [추가] 샘플 길이 피팅 (저각도 모양 결정) 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.min = 2.0 # 최소 2mm + p_slen.max = 50.0 # 최대 50mm # [추가] 빔 폭 (보통 고정하거나 미세 조정) p_beam = pars.append("v.set_beam_w", model) - p_beam.value = 0.1 # 초기값 0.1mm (슬릿 크기) + p_beam.value = 0.1 # 초기값 0.1mm (슬릿 크기) p_beam.min = 0.01 p_beam.max = 0.5 - p_beam.fit = False # 일단 고정 (원하면 True) + p_beam.fit = False # 일단 고정 (원하면 True) 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 + model.set_fom_func(fom_funcs.diff) # Linear Scale # I0와 Sample Length는 서로 스케일에 영향을 주므로 같이 피팅 p_i0.fit = True @@ -226,12 +226,12 @@ def plot(self): R_sim = self.model.data[0].y_sim plt.figure(figsize=(8, 6)) - plt.plot(q, R_meas, 'ko', label='Measured', markersize=4, alpha=0.6) - plt.plot(q, R_sim, 'r-', label='Fit', linewidth=2) - plt.yscale('log') - plt.xlabel(r'q [$\AA^{-1}$]') - plt.ylabel('Reflectivity') - plt.title(f'Fit Result (FOM: {self.model.fom:.4e})') + plt.plot(q, R_meas, "ko", label="Measured", markersize=4, alpha=0.6) + plt.plot(q, R_sim, "r-", label="Fit", linewidth=2) + plt.yscale("log") + plt.xlabel(r"q [$\AA^{-1}$]") + plt.ylabel("Reflectivity") + plt.title(f"Fit Result (FOM: {self.model.fom:.4e})") plt.legend() plt.grid(True, alpha=0.3) plt.show() diff --git a/runs/exp05_1layer_mask/inference.py b/runs/exp05_1layer_mask/inference.py index 54e5184..7f4c39a 100644 --- a/runs/exp05_1layer_mask/inference.py +++ b/runs/exp05_1layer_mask/inference.py @@ -48,11 +48,7 @@ def __init__(self, exp_dir=None): # 3. 전처리기 초기화 # 여기서 target_qs를 넘겨주면, process_input에서 # 들어오는 모든 데이터(등간격)를 이 target_qs(비선형)에 맞춰 내삽합니다. - self.processor = XRRPreprocessor( - qs=self.target_qs, - stats_file=self.stats_file, - device=self.device - ) + self.processor = XRRPreprocessor(qs=self.target_qs, stats_file=self.stats_file, device=self.device) # 4. 모델 로드 self._load_model() @@ -61,16 +57,19 @@ def _load_model(self): ckpt = torch.load(self.checkpoint_file, map_location=self.device) # Config Fallback - model_args = ckpt.get('config', {}).get('model_args', { - 'q_len': len(self.target_qs), - 'input_channels': 2, - 'n_channels': CONFIG["model"]["n_channels"], - 'depth': CONFIG["model"]["depth"], - 'mlp_hidden': CONFIG["model"]["mlp_hidden"], - }) + model_args = ckpt.get("config", {}).get( + "model_args", + { + "q_len": len(self.target_qs), + "input_channels": 2, + "n_channels": CONFIG["model"]["n_channels"], + "depth": CONFIG["model"]["depth"], + "mlp_hidden": CONFIG["model"]["mlp_hidden"], + }, + ) self.model = XRR1DRegressor(**model_args).to(self.device) - self.model.load_state_dict(ckpt['model_state_dict']) + self.model.load_state_dict(ckpt["model_state_dict"]) self.model.eval() print(f"[Inference] Model loaded from {self.checkpoint_file}") diff --git a/runs/exp05_1layer_mask/inference_pipeline.py b/runs/exp05_1layer_mask/inference_pipeline.py index ad2a6bb..65c9894 100644 --- a/runs/exp05_1layer_mask/inference_pipeline.py +++ b/runs/exp05_1layer_mask/inference_pipeline.py @@ -9,11 +9,7 @@ def run_xrr_analysis( - data_path: str | Path, - weights_dir: str | Path, - inference_engine: XRRInferenceEngine | None = None, - verbose: bool = True, - show_plot: bool = True + data_path: str | Path, weights_dir: str | Path, inference_engine: XRRInferenceEngine | None = None, verbose: bool = True, show_plot: bool = True ) -> dict: """ 단일 XRR 데이터 파일에 대해 [로드 -> NN 추론 -> GenX 피팅] 파이프라인을 실행합니다. @@ -41,9 +37,9 @@ def run_xrr_analysis( weights_dir = Path(weights_dir) if verbose: - print("\n" + "="*60) + print("\n" + "=" * 60) print(f"🚀 XRR Analysis Pipeline: {data_path.name}") - print("="*60) + print("=" * 60) # --------------------------------------------------------- # 1. 데이터 로드 (Data Loading) @@ -98,12 +94,12 @@ def run_xrr_analysis( # 4. 결과 정리 및 시각화 # --------------------------------------------------------- if verbose: - print("\n" + "-"*40) + print("\n" + "-" * 40) print("FINAL ANALYSIS RESULT") - print("-"*40) + print("-" * 40) for param_name, value in final_results.items(): print(f"{param_name:15s}: {value:.4f}") - print("="*40) + print("=" * 40) if show_plot: fitter.plot() @@ -115,9 +111,10 @@ def run_xrr_analysis( "fitter": fitter, "q": q_raw, "R_measured": R_raw, - "R_fit": fitter.model.data[0].y_sim + "R_fit": fitter.model.data[0].y_sim, } + # ========================================================= # 사용 예시 (Main) # ========================================================= @@ -128,12 +125,7 @@ def main(): try: # 함수 호출 - result = run_xrr_analysis( - data_path=target_file, - weights_dir=weights_path, - verbose=True, - show_plot=True - ) + 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} Å") @@ -141,5 +133,6 @@ def main(): except Exception as e: print(f"오류 발생: {e}") + if __name__ == "__main__": main() diff --git a/runs/exp05_1layer_mask/inspect_data.py b/runs/exp05_1layer_mask/inspect_data.py index 0646f1f..24f6575 100644 --- a/runs/exp05_1layer_mask/inspect_data.py +++ b/runs/exp05_1layer_mask/inspect_data.py @@ -22,11 +22,11 @@ def inspect_statistics(stats_file): print(f"❌ Stats file not found: {stats_file}") return None, None - stats = torch.load(stats_file, map_location='cpu') + stats = torch.load(stats_file, map_location="cpu") # Check Param Stats - p_mean = stats['param_mean'].numpy() - p_std = stats['param_std'].numpy() + p_mean = stats["param_mean"].numpy() + p_std = stats["param_std"].numpy() print(f"Parameter Mean: {p_mean}") print(f"Parameter Std : {p_std}") @@ -39,36 +39,29 @@ def inspect_statistics(stats_file): return p_mean, p_std + 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] + with h5py.File(h5_file, "r") as hf: + q_raw = hf["q"][:] + R_raw = hf["R"][sample_idx] # Load params - d = hf['thickness'][sample_idx] - sig = hf['roughness'][sample_idx] - sld = hf['sld'][sample_idx] + d = hf["thickness"][sample_idx] + sig = hf["roughness"][sample_idx] + sld = hf["sld"][sample_idx] print(f"Target Params: d={d}, sig={sig}, sld={sld}") # 2. Setup Preprocessor # Config에서 설정 가져오기 - sim_conf = CONFIG['simulation'] + sim_conf = CONFIG["simulation"] # Power 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 - ) + 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 @@ -95,7 +88,7 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): # Plot 1: Raw Linear Data plt.subplot(2, 2, 1) - plt.plot(q_raw, np.log10(np.maximum(R_raw, 1e-15)), 'k-', label='Raw Data (Linear Grid)') + plt.plot(q_raw, np.log10(np.maximum(R_raw, 1e-15)), "k-", label="Raw Data (Linear Grid)") plt.title(f"Raw Input (Linear q)\nRange: {q_raw.min():.2f} ~ {q_raw.max():.2f}") plt.xlabel("q") plt.ylabel("log10(R)") @@ -104,8 +97,8 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): # Plot 2: Model Input (Resampled) plt.subplot(2, 2, 2) - plt.plot(target_qs, model_input_R, 'b.-', label='Processed Input (Power Grid)') - plt.plot(target_qs, model_input_M * np.min(model_input_R), 'r-', alpha=0.3, label='Mask') + plt.plot(target_qs, model_input_R, "b.-", label="Processed Input (Power Grid)") + plt.plot(target_qs, model_input_M * np.min(model_input_R), "r-", alpha=0.3, label="Mask") plt.title(f"Model Input (Resampled)\nRange: {target_qs.min():.2f} ~ {target_qs.max():.2f}") plt.xlabel("q (Power Space)") @@ -118,15 +111,14 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=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]) - plt.text(0.5, 0.5, f"Background Level: {bg_val:.2f}", - transform=plt.gca().transAxes, fontsize=12, color='red', fontweight='bold') + plt.text(0.5, 0.5, f"Background Level: {bg_val:.2f}", transform=plt.gca().transAxes, fontsize=12, color="red", fontweight="bold") if bg_val > -5.0: - print(f"🚨 CRITICAL WARNING: Background level is too high ({bg_val:.2f})! It should be around -15.0.") - print(" -> Fix 'dataset.py' np.interp left/right arguments.") + print(f"🚨 CRITICAL WARNING: Background level is too high ({bg_val:.2f})! It should be around -15.0.") + print(" -> Fix 'dataset.py' np.interp left/right arguments.") # Plot 3: Grid Density Check plt.subplot(2, 2, 3) - plt.plot(target_qs[:-1], np.diff(target_qs), 'g.-') + plt.plot(target_qs[:-1], np.diff(target_qs), "g.-") plt.title(f"Grid Spacing (dq)\nPower={sim_conf['power']}") plt.xlabel("q") plt.ylabel("dq") @@ -135,6 +127,7 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): plt.tight_layout() plt.show() + if __name__ == "__main__": base_path = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] h5_path = base_path / "dataset.h5" @@ -143,5 +136,4 @@ def inspect_preprocessing(h5_file, stats_file, sample_idx=0): inspect_statistics(stats_path) for i in [0, 100, 500]: - - inspect_preprocessing(h5_path, stats_path, sample_idx=i) + inspect_preprocessing(h5_path, stats_path, sample_idx=i) diff --git a/runs/exp05_1layer_mask/main.py b/runs/exp05_1layer_mask/main.py index 161fe65..5442091 100644 --- a/runs/exp05_1layer_mask/main.py +++ b/runs/exp05_1layer_mask/main.py @@ -52,42 +52,30 @@ def get_dataloaders(qs: np.ndarray, config: dict, h5_file: Path, stats_file: Pat "val_ratio": config["training"]["val_ratio"], "test_ratio": config["training"]["test_ratio"], "augment": True, - "expand_factor" : config["training"]["expand_factor"], - "aug_prob" : config["training"]["aug_prob"], - "min_scan_range" : 0.15, - "q_shift_sigma" : config["training"]["q_shift_sigma"], - "intensity_scale" : config["training"]["intensity_scale"] + "expand_factor": config["training"]["expand_factor"], + "aug_prob": config["training"]["aug_prob"], + "min_scan_range": 0.15, + "q_shift_sigma": config["training"]["q_shift_sigma"], + "intensity_scale": config["training"]["intensity_scale"], } # Create Dataset instances train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") - val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") - test_set = XRR1LayerDataset(**dataset_kwargs, mode="test") + val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") + test_set = XRR1LayerDataset(**dataset_kwargs, mode="test") # Create DataLoaders batch_size = config["training"]["batch_size"] num_workers = config["training"]["num_workers"] train_loader = DataLoader( - train_set, - batch_size=batch_size, - shuffle=True, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + train_set, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) val_loader = DataLoader( - val_set, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + val_set, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) test_loader = DataLoader( - test_set, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + test_set, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) print(f"Dataset sizes: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") @@ -116,10 +104,8 @@ def main(): config_file_json = exp_dir / "config.json" qs_file_npy = exp_dir / "qs.npy" qs: np.ndarray = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"]) + CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"] + ) np.save(qs_file_npy, qs) print(f"qs file saved at '{qs_file_npy}'") @@ -154,7 +140,7 @@ def main(): save_dir=exp_dir, lr=CONFIG["training"]["lr"], weight_decay=CONFIG["training"]["weight_decay"], - patience=CONFIG["training"]["patience"] + patience=CONFIG["training"]["patience"], ) print("Starting training...") @@ -165,21 +151,16 @@ def main(): print(f"last_checkpoint_file: '{last_checkpoint_file}' has not found") trainer.train(CONFIG["training"]["epochs"]) - print("\n" + "="*50) + print("\n" + "=" * 50) print("Performing Final Test Evaluation") - print("="*50) + print("=" * 50) if not checkpoint_file.exists(): print(f"No checkpoint found at {checkpoint_file}. Skipping evaluation.") return evaluate_pipeline( - test_loader=test_loader, - checkpoint_path=checkpoint_file, - stats_path=stats_file, - qs=qs, - output_dir=exp_dir, - calc_physics_fom=True + test_loader=test_loader, checkpoint_path=checkpoint_file, stats_path=stats_file, qs=qs, output_dir=exp_dir, calc_physics_fom=True ) diff --git a/runs/exp05_1layer_mask/notebooks/inference_sim.ipynb b/runs/exp05_1layer_mask/notebooks/inference_sim.ipynb index 5166b71..a44c836 100644 --- a/runs/exp05_1layer_mask/notebooks/inference_sim.ipynb +++ b/runs/exp05_1layer_mask/notebooks/inference_sim.ipynb @@ -155,23 +155,23 @@ "\n", "# 피팅 실행\n", "final_results = fitter.run(verbose=True)\n", - " # ---------------------------------------------------------\n", + "# ---------------------------------------------------------\n", "\n", - "print(\"\\n\" + \"-\"*40)\n", + "print(\"\\n\" + \"-\" * 40)\n", "print(\"FINAL ANALYSIS RESULT\")\n", - "print(\"-\"*40)\n", + "print(\"-\" * 40)\n", "for param_name, value in final_results.items():\n", " print(f\"{param_name:15s}: {value:.4f}\")\n", - "print(\"=\"*40)\n", + "print(\"=\" * 40)\n", "\n", "\n", "fitter.plot()\n", "\n", "# 결과 반환\n", "result = {\n", - "\"nn_preds\": (pred_d, pred_sig, pred_sld),\n", - "\"final_params\": final_results,\n", - "\"fitter\": fitter,\n", + " \"nn_preds\": (pred_d, pred_sig, pred_sld),\n", + " \"final_params\": final_results,\n", + " \"fitter\": fitter,\n", "}\n", "\n", "for key, val in result.items():\n", diff --git a/runs/exp05_1layer_mask/notebooks/inference_test.ipynb b/runs/exp05_1layer_mask/notebooks/inference_test.ipynb index 172febe..cf97c68 100644 --- a/runs/exp05_1layer_mask/notebooks/inference_test.ipynb +++ b/runs/exp05_1layer_mask/notebooks/inference_test.ipynb @@ -222,23 +222,23 @@ "\n", "# 피팅 실행\n", "final_results = fitter.run(verbose=True)\n", - " # ---------------------------------------------------------\n", + "# ---------------------------------------------------------\n", "\n", - "print(\"\\n\" + \"-\"*40)\n", + "print(\"\\n\" + \"-\" * 40)\n", "print(\"FINAL ANALYSIS RESULT\")\n", - "print(\"-\"*40)\n", + "print(\"-\" * 40)\n", "for param_name, value in final_results.items():\n", " print(f\"{param_name:15s}: {value:.4f}\")\n", - "print(\"=\"*40)\n", + "print(\"=\" * 40)\n", "\n", "\n", "fitter.plot()\n", "\n", "# 결과 반환\n", "result = {\n", - "\"nn_preds\": (pred_d, pred_sig, pred_sld),\n", - "\"final_params\": final_results,\n", - "\"fitter\": fitter,\n", + " \"nn_preds\": (pred_d, pred_sig, pred_sld),\n", + " \"final_params\": final_results,\n", + " \"fitter\": fitter,\n", "}\n", "\n", "for key, val in result.items():\n", @@ -269,43 +269,43 @@ "sim_refl = param2refl(q_raw, [param])\n", "\n", "# 1. LaTeX 스타일 폰트 설정 (Times New Roman 느낌)\n", - "plt.rcParams.update({\n", - " 'font.family': 'serif',\n", - " 'mathtext.fontset': 'cm', # Computer Modern (TeX 폰트)\n", - " 'font.size': 12,\n", - "})\n", + "plt.rcParams.update(\n", + " {\n", + " \"font.family\": \"serif\",\n", + " \"mathtext.fontset\": \"cm\", # Computer Modern (TeX 폰트)\n", + " \"font.size\": 12,\n", + " }\n", + ")\n", "\n", "fig, ax = plt.subplots(1, 1, figsize=(6, 4.5))\n", "\n", "# 2. 플롯 그리기\n", "# 측정 데이터: 작고 진한 점\n", - "ax.plot(q_raw, R_raw / R_raw.max(), 'k.',\n", - " markersize=4,\n", - " alpha=0.4,\n", - " label=\"Measured\")\n", + "ax.plot(q_raw, R_raw / R_raw.max(), \"k.\", markersize=4, alpha=0.4, label=\"Measured\")\n", "\n", "# AI 초기값: 회색 혹은 옅은 색 점선\n", - "ax.plot(q_raw, sim_refl, linestyle='--',\n", - " color='gray',\n", - " linewidth=1.5,\n", - " label=\"AI Guess\")\n", + "ax.plot(q_raw, sim_refl, linestyle=\"--\", color=\"gray\", linewidth=1.5, label=\"AI Guess\")\n", "\n", "# 파인 튜닝: 진한 빨강 실선\n", - "ax.plot(q_raw, fitter.R_sim, linestyle='-',\n", - " color='#D62728', # Matplotlib 기본 빨강보다 약간 고급진 색\n", - " linewidth=1.5,\n", - " label=\"Fine Tuned\")\n", + "ax.plot(\n", + " q_raw,\n", + " fitter.R_sim,\n", + " linestyle=\"-\",\n", + " color=\"#D62728\", # Matplotlib 기본 빨강보다 약간 고급진 색\n", + " linewidth=1.5,\n", + " label=\"Fine Tuned\",\n", + ")\n", "\n", "# 3. 축 설정\n", "ax.set_yscale(\"log\")\n", "ax.set_xlabel(r\"$Q \\ [\\AA^{-1}]$\", fontsize=14)\n", - "ax.set_ylabel(r\"$R(Q)/R_F(Q)$\", fontsize=14) # 혹은 단순히 Reflectivity\n", + "ax.set_ylabel(r\"$R(Q)/R_F(Q)$\", fontsize=14) # 혹은 단순히 Reflectivity\n", "\n", "# 4. 틱(Tick) 설정 - 사방으로 눈금 표시 (Box style)\n", - "ax.tick_params(direction='in', which='both', top=True, right=True)\n", + "ax.tick_params(direction=\"in\", which=\"both\", top=True, right=True)\n", "\n", "# 5. 범례\n", - "ax.legend(frameon=True, fancybox=False, edgecolor='black', fontsize=10)\n", + "ax.legend(frameon=True, fancybox=False, edgecolor=\"black\", fontsize=10)\n", "\n", "plt.tight_layout()\n", "plt.show()" @@ -354,8 +354,8 @@ "# Manual: 평균 30분 (1800초) ~ 60분 범위\n", "# Hybrid: 평균 2초 ~ 4초 범위\n", "means = [1800, 3]\n", - "labels = ['Manual Fitting\\n(Expert)', 'Hybrid Pipeline\\n(Ours)']\n", - "colors = ['#bdbdbd', '#2e7d32'] # 회색 vs 초록색\n", + "labels = [\"Manual Fitting\\n(Expert)\", \"Hybrid Pipeline\\n(Ours)\"]\n", + "colors = [\"#bdbdbd\", \"#2e7d32\"] # 회색 vs 초록색\n", "\n", "fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(5, 6))\n", "fig.subplots_adjust(hspace=0.1) # 위아래 그래프 간격 조절\n", @@ -366,19 +366,19 @@ "\n", "# 축 범위 설정 (Broken Axis 효과)\n", "ax1.set_ylim(1500, 2000) # 위쪽 그래프: 1500~2000초 구간\n", - "ax2.set_ylim(0, 100) # 아래쪽 그래프: 0~100초 구간\n", + "ax2.set_ylim(0, 100) # 아래쪽 그래프: 0~100초 구간\n", "\n", "# 위쪽 그래프의 X축 눈금 제거\n", - "ax1.spines['bottom'].set_visible(False)\n", - "ax2.spines['top'].set_visible(False)\n", + "ax1.spines[\"bottom\"].set_visible(False)\n", + "ax2.spines[\"top\"].set_visible(False)\n", "ax1.xaxis.tick_top()\n", "ax1.tick_params(labeltop=False) # 위쪽 라벨 제거\n", "ax2.xaxis.tick_bottom()\n", "\n", "# 빗금(Diagonal lines)으로 잘린 부분 표현\n", - "d = .015\n", - "kwargs = {\"transform\": ax1.transAxes, \"color\": 'k', \"clip_on\": False}\n", - "ax1.plot((-d, +d), (-d, +d), **kwargs) # top-left diagonal\n", + "d = 0.015\n", + "kwargs = {\"transform\": ax1.transAxes, \"color\": \"k\", \"clip_on\": False}\n", + "ax1.plot((-d, +d), (-d, +d), **kwargs) # top-left diagonal\n", "ax1.plot((1 - d, 1 + d), (-d, +d), **kwargs) # top-right diagonal\n", "\n", "kwargs.update(transform=ax2.transAxes) # switch to the bottom axes\n", @@ -386,12 +386,12 @@ "ax2.plot((1 - d, 1 + d), (1 - d, 1 + d), **kwargs) # bottom-right diagonal\n", "\n", "# 텍스트 라벨 추가 (가장 중요!)\n", - "ax1.text(0, 1850, \"~ 30 mins\", ha='center', va='bottom', fontsize=14, fontweight='bold')\n", - "ax2.text(1, 10, \"< 3 sec\", ha='center', va='bottom', fontsize=14, fontweight='bold', color='#2e7d32')\n", + "ax1.text(0, 1850, \"~ 30 mins\", ha=\"center\", va=\"bottom\", fontsize=14, fontweight=\"bold\")\n", + "ax2.text(1, 10, \"< 3 sec\", ha=\"center\", va=\"bottom\", fontsize=14, fontweight=\"bold\", color=\"#2e7d32\")\n", "\n", "# 제목 및 Y축 라벨\n", - "ax1.set_title(\"Processing Time per Scan\", fontsize=14, fontweight='bold', pad=20)\n", - "fig.text(0.02, 0.5, 'Time (seconds)', va='center', rotation='vertical', fontsize=12)\n", + "ax1.set_title(\"Processing Time per Scan\", fontsize=14, fontweight=\"bold\", pad=20)\n", + "fig.text(0.02, 0.5, \"Time (seconds)\", va=\"center\", rotation=\"vertical\", fontsize=12)\n", "\n", "plt.show()" ] @@ -429,33 +429,39 @@ "manual_good = np.random.lognormal(mean=np.log(0.5), sigma=0.5, size=int(n_samples * 0.3))\n", "manual_bad = np.random.normal(loc=5.0, scale=1.5, size=int(n_samples * 0.7))\n", "manual_errors = np.concatenate([manual_good, manual_bad])\n", - "manual_errors = manual_errors[manual_errors > 0] # 음수 제거\n", + "manual_errors = manual_errors[manual_errors > 0] # 음수 제거\n", "\n", "# 그래프 그리기\n", - "plt.figure(figsize=(7, 5)) # 포스터 공간에 맞춰 비율 조정\n", + "plt.figure(figsize=(7, 5)) # 포스터 공간에 맞춰 비율 조정\n", "\n", "# 히스토그램 그리기\n", - "plt.hist(manual_errors, bins=50, alpha=0.5, color='#bdbdbd', label='Manual / Conventional', density=True, edgecolor='grey')\n", - "plt.hist(hybrid_errors, bins=50, alpha=0.8, color='#2e7d32', label='Hybrid Pipeline (Ours)', density=True, edgecolor='darkgreen')\n", + "plt.hist(manual_errors, bins=50, alpha=0.5, color=\"#bdbdbd\", label=\"Manual / Conventional\", density=True, edgecolor=\"grey\")\n", + "plt.hist(hybrid_errors, bins=50, alpha=0.8, color=\"#2e7d32\", label=\"Hybrid Pipeline (Ours)\", density=True, edgecolor=\"darkgreen\")\n", "\n", "# 라벨 및 스타일 설정\n", - "plt.xlabel(r'Final Cost Function Value ($\\chi^2$)', fontsize=14, fontweight='bold')\n", - "plt.ylabel('Frequency (Density)', fontsize=12)\n", - "plt.title('Convergence Stability Comparison', fontsize=15, fontweight='bold', pad=15)\n", + "plt.xlabel(r\"Final Cost Function Value ($\\chi^2$)\", fontsize=14, fontweight=\"bold\")\n", + "plt.ylabel(\"Frequency (Density)\", fontsize=12)\n", + "plt.title(\"Convergence Stability Comparison\", fontsize=15, fontweight=\"bold\", pad=15)\n", "\n", "# 화살표 및 텍스트 주석 (Highlight)\n", - "plt.annotate('Global Minimum\\n(Consistent Accuracy)', xy=(0.5, 0.6), xytext=(2, 0.7),\n", - " arrowprops={\"facecolor\": '#2e7d32', \"shrink\": 0.05},\n", - " fontsize=12, fontweight='bold', color='#2e7d32')\n", - "\n", - "plt.text(6, 0.15, 'Trapped in\\nLocal Minima', fontsize=12, fontweight='bold', color='#666666', ha='center')\n", + "plt.annotate(\n", + " \"Global Minimum\\n(Consistent Accuracy)\",\n", + " xy=(0.5, 0.6),\n", + " xytext=(2, 0.7),\n", + " arrowprops={\"facecolor\": \"#2e7d32\", \"shrink\": 0.05},\n", + " fontsize=12,\n", + " fontweight=\"bold\",\n", + " color=\"#2e7d32\",\n", + ")\n", + "\n", + "plt.text(6, 0.15, \"Trapped in\\nLocal Minima\", fontsize=12, fontweight=\"bold\", color=\"#666666\", ha=\"center\")\n", "\n", "plt.legend(fontsize=12)\n", - "plt.xlim(0, 10) # 핵심 영역만 보여주기 위해 X축 제한\n", + "plt.xlim(0, 10) # 핵심 영역만 보여주기 위해 X축 제한\n", "plt.yticks([]) # Y축 숫자는 중요하지 않으므로 제거 (깔끔하게)\n", - "plt.gca().spines['top'].set_visible(False)\n", - "plt.gca().spines['right'].set_visible(False)\n", - "plt.gca().spines['left'].set_visible(False) # 왼쪽 축도 없애고 바닥만 남김\n", + "plt.gca().spines[\"top\"].set_visible(False)\n", + "plt.gca().spines[\"right\"].set_visible(False)\n", + "plt.gca().spines[\"left\"].set_visible(False) # 왼쪽 축도 없애고 바닥만 남김\n", "\n", "plt.tight_layout()\n", "plt.show()\n", diff --git a/runs/exp05_1layer_mask/notebooks/ml_fitting_real.ipynb b/runs/exp05_1layer_mask/notebooks/ml_fitting_real.ipynb index 0643d59..f8f0b7a 100644 --- a/runs/exp05_1layer_mask/notebooks/ml_fitting_real.ipynb +++ b/runs/exp05_1layer_mask/notebooks/ml_fitting_real.ipynb @@ -76,16 +76,19 @@ "\n", " # 체크포인트에서 모델 설정 가져오기 (없으면 Config 사용)\n", " ckpt = torch.load(self.checkpoint_file, map_location=self.device)\n", - " model_args = ckpt.get('config', {}).get('model_args', {\n", - " 'q_len': self.n_points,\n", - " 'input_channels': 2, # [중요] 2채널 고정\n", - " 'n_channels': CONFIG[\"model\"][\"n_channels\"],\n", - " 'depth': CONFIG[\"model\"][\"depth\"],\n", - " 'mlp_hidden': CONFIG[\"model\"][\"mlp_hidden\"],\n", - " })\n", + " model_args = ckpt.get(\"config\", {}).get(\n", + " \"model_args\",\n", + " {\n", + " \"q_len\": self.n_points,\n", + " \"input_channels\": 2, # [중요] 2채널 고정\n", + " \"n_channels\": CONFIG[\"model\"][\"n_channels\"],\n", + " \"depth\": CONFIG[\"model\"][\"depth\"],\n", + " \"mlp_hidden\": CONFIG[\"model\"][\"mlp_hidden\"],\n", + " },\n", + " )\n", "\n", " self.model = XRR1DRegressor(**model_args).to(self.device)\n", - " self.model.load_state_dict(ckpt['model_state_dict'])\n", + " self.model.load_state_dict(ckpt[\"model_state_dict\"])\n", " self.model.eval()\n", " print(f\"[Inference] Model loaded from {self.checkpoint_file}\")\n", "\n", @@ -277,8 +280,8 @@ "\n", "# 1) 측정 데이터 준비\n", "qz = test_q\n", - "R = refl\n", - "dR = np.maximum(refl*0.3, 1e-8)\n", + "R = refl\n", + "dR = np.maximum(refl * 0.3, 1e-8)\n", "\n", "ds = DataSet(name=\"XRR 1-layer\")\n", "ds.x_raw = qz\n", @@ -414,7 +417,7 @@ "# ---------------------------------------------------------\n", "print(\"--- Step 1: Fitting I0 ---\")\n", "model.set_fom_func(fom_funcs.diff)\n", - "res_i0 = model.bumps_fit(method=\"dream\", steps=200) # 가볍게 DE 사용\n", + "res_i0 = model.bumps_fit(method=\"dream\", steps=200) # 가볍게 DE 사용\n", "model.bumps_update_parameters(res_i0)\n", "print(f\"Fitted I0: {p_i0.value}\")\n", "\n", @@ -426,8 +429,8 @@ "\n", "p_thick.fit = True\n", "p_rough.fit = True\n", - "p_sld.fit = True\n", - "p_i0.fit = False # I0 고정\n", + "p_sld.fit = True\n", + "p_i0.fit = False # I0 고정\n", "\n", "# Newton은 국소 최적화라 초기값이 나쁘면 발산할 수 있습니다. 'de' 추천.\n", "res_final = model.bumps_fit(method=\"dream\", steps=1000, pop=20, tol=0.01)\n", @@ -492,7 +495,7 @@ "# 1) 데이터 준비\n", "# ---------------------------------------------------------\n", "qz = param2refl(tth_measured)\n", - "R = refl\n", + "R = refl\n", "dR = np.maximum(R * 0.1, 1e-9)\n", "\n", "ds = DataSet(name=\"XRR 2-Layer Fit\")\n", @@ -506,12 +509,12 @@ "# 초기값 설정\n", "val_film_thick = param.thickness\n", "val_film_rough = param.roughness\n", - "val_film_sld = param.sld * 1e6 if param.sld < 1.0 else param.sld\n", + "val_film_sld = param.sld * 1e6 if param.sld < 1.0 else param.sld\n", "\n", "# SiO2 (자연 산화막) 초기값\n", - "val_sio_d = 15.0\n", + "val_sio_d = 15.0\n", "val_sio_sig = 3.0\n", - "val_sio_sld = 18.8 # 초기값\n", + "val_sio_sld = 18.8 # 초기값\n", "\n", "# ---------------------------------------------------------\n", "# 2) GenX 스크립트\n", @@ -628,41 +631,41 @@ "# [Material 1: Main Film]\n", "p_f_d = pars.append(\"vars.set_film_d\", model)\n", "p_f_d.value = val_film_thick\n", - "p_f_d.min=10\n", - "p_f_d.max=1000\n", + "p_f_d.min = 10\n", + "p_f_d.max = 1000\n", "\n", "p_f_sig = pars.append(\"vars.set_film_sig\", model)\n", "p_f_sig.value = val_film_rough\n", - "p_f_sig.min=0\n", - "p_f_sig.max=30\n", + "p_f_sig.min = 0\n", + "p_f_sig.max = 30\n", "\n", "p_f_sld = pars.append(\"vars.set_film_sld\", model)\n", "p_f_sld.value = val_film_sld\n", - "p_f_sld.min=10\n", - "p_f_sld.max=50\n", + "p_f_sld.min = 10\n", + "p_f_sld.max = 50\n", "\n", "# [Material 2: Surface SiO2]\n", "p_s_d = pars.append(\"vars.set_sio_d\", model)\n", "p_s_d.value = val_sio_d\n", - "p_s_d.min=5\n", - "p_s_d.max=50\n", + "p_s_d.min = 5\n", + "p_s_d.max = 50\n", "\n", "p_s_sig = pars.append(\"vars.set_sio_sig\", model)\n", "p_s_sig.value = val_sio_sig\n", - "p_s_sig.min=0\n", - "p_s_sig.max=20\n", + "p_s_sig.min = 0\n", + "p_s_sig.max = 20\n", "\n", "# [추가됨] SiO2 SLD 파라미터\n", "p_s_sld = pars.append(\"vars.set_sio_sld\", model)\n", "p_s_sld.value = val_sio_sld\n", - "p_s_sld.min=10.0\n", - "p_s_sld.max=25.0 # SiO2는 보통 18~20 사이\n", + "p_s_sld.min = 10.0\n", + "p_s_sld.max = 25.0 # SiO2는 보통 18~20 사이\n", "\n", "# [Instrument: I0]\n", "p_i0 = pars.append(\"vars.set_i0\", model)\n", "p_i0.value = 1.0\n", - "p_i0.min=0.1\n", - "p_i0.max=100.0\n", + "p_i0.min = 0.1\n", + "p_i0.max = 100.0\n", "\n", "model.parameters = pars\n", "\n", @@ -722,7 +725,7 @@ "print(\"-\" * 30)\n", "print(f\"SiO2 Thickness : {p_s_d.value:.2f} Å\")\n", "print(f\"SiO2 Roughness : {p_s_sig.value:.2f} Å\")\n", - "print(f\"SiO2 SLD : {p_s_sld.value:.3f}\") # 수정됨: SiO2 변수 출력" + "print(f\"SiO2 SLD : {p_s_sld.value:.3f}\") # 수정됨: SiO2 변수 출력" ] }, { diff --git a/runs/exp05_1layer_mask/simulate.py b/runs/exp05_1layer_mask/simulate.py index 8be0a2e..f6c00f0 100644 --- a/runs/exp05_1layer_mask/simulate.py +++ b/runs/exp05_1layer_mask/simulate.py @@ -22,21 +22,14 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): 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 - } + simulator_args: dict = {"qs": qs, "n_layers": 1, "n_samples": simulation["n_samples"], "has_noise": False} if param_ranges["thickness"] is not None: simulator_args["thickness_range"] = param_ranges["thickness"] if param_ranges["roughness"] is not None: simulator_args["roughness_range"] = param_ranges["roughness"] if param_ranges["sld"] is not None: simulator_args["sld_range"] = param_ranges["sld"] - simulator = XRRSimulator( - **simulator_args - ) + simulator = XRRSimulator(**simulator_args) simulator.save_hdf5(h5_file, show_progress=True) @@ -56,8 +49,6 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): h5_file = exp_dir / "dataset.h5" qs: np.ndarray = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"]) + CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"] + ) generate_1layer_data(qs, CONFIG, h5_file) diff --git a/runs/exp05_1layer_mask/train.py b/runs/exp05_1layer_mask/train.py index d58cc93..fa693e5 100644 --- a/runs/exp05_1layer_mask/train.py +++ b/runs/exp05_1layer_mask/train.py @@ -13,15 +13,19 @@ class Trainer: Trainer class for XRR 1D Regressor. Handles training loop, validation, checkpointing, and early stopping. """ - def __init__(self, - model: nn.Module, train_loader: DataLoader, + + def __init__( + self, + model: nn.Module, + train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, - lr: float = 1e-3, weight_decay: float = 1e-4, - patience: int = 15 + lr: float = 1e-3, + weight_decay: float = 1e-4, + patience: int = 15, ): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = model.to(self.device) self.train_loader = train_loader @@ -39,23 +43,21 @@ def __init__(self, # Scheduler: Reduce Learning Rate when validation loss plateaus # Note: 'verbose' argument is deprecated in newer PyTorch versions, removed here. - self.scheduler = ReduceLROnPlateau( - self.optimizer, mode='min', patience=5, factor=0.5 - ) + self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=5, factor=0.5) # Training State - self.best_val_loss = float('inf') + self.best_val_loss = float("inf") self.patience_counter = 0 self.early_stop_patience = patience self.history = {"train": [], "val": [], "lr": []} - self.scaler = torch.amp.GradScaler('cuda') + self.scaler = torch.amp.GradScaler("cuda") print(f"🚀 Trainer initialized on {self.device}") def _train_epoch(self, epoch: int, total_epochs: int) -> float: """Runs a single training epoch.""" - if self.device.type == 'cuda': + if self.device.type == "cuda": torch.cuda.empty_cache() self.model.train() @@ -71,7 +73,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.optimizer.zero_grad(set_to_none=True) # Forward pass - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): preds = self.model(inputs) loss = self.criterion(preds, targets) @@ -87,7 +89,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.scaler.update() running_loss += loss.item() - pbar.set_postfix({'loss': f'{loss.item():.5f}'}) + pbar.set_postfix({"loss": f"{loss.item():.5f}"}) avg_loss = running_loss / len(self.train_loader) return avg_loss @@ -124,15 +126,15 @@ def train(self, epochs: int, resume_from: str | Path | None = None): val_loss = self._validate() # Update History - self.history['train'].append(train_loss) - self.history['val'].append(val_loss) - self.history['lr'].append(self.optimizer.param_groups[0]['lr']) + self.history["train"].append(train_loss) + self.history["val"].append(val_loss) + self.history["lr"].append(self.optimizer.param_groups[0]["lr"]) # Scheduler Step self.scheduler.step(val_loss) # Log current Learning Rate - current_lr = self.optimizer.param_groups[0]['lr'] + current_lr = self.optimizer.param_groups[0]["lr"] print(f"Epoch {epoch:03d} | Train Loss: {train_loss:.6f} | Val Loss: {val_loss:.6f} | LR: {current_lr:.2e}") @@ -158,20 +160,21 @@ def _save_checkpoint(self, filename: str, epoch: int, val_loss: float): path = self.checkpoint_dir / filename # Save model configuration for easier inference later - model_config = self.model.config if hasattr(self.model, 'config') else {} - - torch.save({ - 'epoch': epoch, - 'model_state_dict': self.model.state_dict(), - 'optimizer_state_dict': self.optimizer.state_dict(), - 'scheduler_state_dict': self.scheduler.state_dict(), - 'val_loss': val_loss, - 'best_val_loss': self.best_val_loss, - 'history': self.history, - 'config': { - 'model_args': model_config - } - }, path) + model_config = self.model.config if hasattr(self.model, "config") else {} + + torch.save( + { + "epoch": epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "scheduler_state_dict": self.scheduler.state_dict(), + "val_loss": val_loss, + "best_val_loss": self.best_val_loss, + "history": self.history, + "config": {"model_args": model_config}, + }, + path, + ) def _load_checkpoint(self, filepath: str | Path) -> int: """Loads a checkpoint to resume training.""" @@ -182,19 +185,19 @@ def _load_checkpoint(self, filepath: str | Path) -> int: checkpoint = torch.load(filepath, map_location=self.device) - self.model.load_state_dict(checkpoint['model_state_dict']) + 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']) - if 'scaler_state_dict' in checkpoint: - self.scaler.load_state_dict(checkpoint['scaler_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"]) + if "scaler_state_dict" in checkpoint: + self.scaler.load_state_dict(checkpoint["scaler_state_dict"]) - self.history = checkpoint.get('history', {'train': [], 'val': [], 'lr': []}) + self.history = checkpoint.get("history", {"train": [], "val": [], "lr": []}) - self.best_val_loss = checkpoint.get('best_val_loss', float('inf')) - start_epoch = checkpoint.get('epoch', 0) + 1 + self.best_val_loss = checkpoint.get("best_val_loss", float("inf")) + start_epoch = checkpoint.get("epoch", 0) + 1 print(f"Checkpoint loaded: {filepath} (Resuming from Epoch {start_epoch})") return start_epoch diff --git a/runs/exp05_1layer_mask/tune.py b/runs/exp05_1layer_mask/tune.py index 4c708a3..bf5a4dc 100644 --- a/runs/exp05_1layer_mask/tune.py +++ b/runs/exp05_1layer_mask/tune.py @@ -16,10 +16,11 @@ # --------------------------------------------------------- # 1. Settings # --------------------------------------------------------- -N_TRIALS = 50 # Total number of trials +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" -STORAGE_URL = "sqlite:///xrr_tuning.db" # Saves results to a database file +STORAGE_URL = "sqlite:///xrr_tuning.db" # Saves results to a database file + def get_dataloaders(batch_size): """ @@ -40,7 +41,7 @@ def get_dataloaders(batch_size): "n_points": CONFIG["simulation"]["q_points"], "augment": True, "aug_prob": 0.5, - "min_scan_range": 0.15 + "min_scan_range": 0.15, } train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") @@ -52,6 +53,7 @@ def get_dataloaders(batch_size): return train_loader, val_loader + def objective(trial): # --------------------------------------------------------- # 2. Define Hyperparameter Search Space @@ -65,7 +67,7 @@ def objective(trial): # Optimization lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True) 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 + batch_size = trial.suggest_categorical("batch_size", [32, 64, 128]) # Keep max low to avoid OOM # --------------------------------------------------------- # 3. Setup (Model, Data, Optimizer) @@ -85,7 +87,7 @@ def objective(trial): n_channels=n_channels, depth=depth, mlp_hidden=mlp_hidden, - dropout=dropout + dropout=dropout, ).to(device) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay) @@ -104,7 +106,7 @@ def objective(trial): optimizer.zero_grad(set_to_none=True) - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): preds = model(inputs) loss = criterion(preds, targets) @@ -132,12 +134,13 @@ def objective(trial): # Handle Pruning (if the trial is hopeless, stop it) if trial.should_prune(): - print(f"[Trial {trial.number}] Pruned at epoch {epoch+1} (Loss: {avg_val_loss:.4f})") + print(f"[Trial {trial.number}] Pruned at epoch {epoch + 1} (Loss: {avg_val_loss:.4f})") raise optuna.TrialPruned() # Return the metric to minimize return avg_val_loss + def save_best_config(study): """Saves the best hyperparameters to a JSON file.""" best_params = study.best_params @@ -152,6 +155,7 @@ def save_best_config(study): json.dump(best_params, f, indent=4) print(f"\nSaved best parameters to: {save_path}") + if __name__ == "__main__": # Clear memory before starting gc.collect() @@ -161,13 +165,7 @@ def save_best_config(study): pruner = optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=3) # Create Study - study = optuna.create_study( - study_name=STUDY_NAME, - storage=STORAGE_URL, - direction="minimize", - pruner=pruner, - load_if_exists=True - ) + study = optuna.create_study(study_name=STUDY_NAME, storage=STORAGE_URL, direction="minimize", pruner=pruner, load_if_exists=True) print(f"Starting optimization with {N_TRIALS} trials...") diff --git a/runs/exp05_1layer_mask/xrr_model.py b/runs/exp05_1layer_mask/xrr_model.py index f073f54..18d61b7 100644 --- a/runs/exp05_1layer_mask/xrr_model.py +++ b/runs/exp05_1layer_mask/xrr_model.py @@ -3,8 +3,16 @@ class XRR1DRegressor(nn.Module): - def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, n_channels: int = 64, depth: int = 4, - mlp_hidden: int = 256, dropout: float = 0.1): + def __init__( + self, + q_len: int, + input_channels: int = 2, + output_dim: int = 3, + n_channels: int = 64, + depth: int = 4, + mlp_hidden: int = 256, + dropout: float = 0.1, + ): """ XRR 1D CNN Regressor @@ -19,13 +27,13 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, n_c """ super().__init__() self.config = { - 'q_len': q_len, - 'input_channels': input_channels, - 'output_dim': output_dim, - 'n_channels': n_channels, - 'depth': depth, - 'mlp_hidden': mlp_hidden, - 'dropout': dropout, + "q_len": q_len, + "input_channels": input_channels, + "output_dim": output_dim, + "n_channels": n_channels, + "depth": depth, + "mlp_hidden": mlp_hidden, + "dropout": dropout, } # --------------------------------------------------------- @@ -36,13 +44,15 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, n_c for i in range(depth): out_ch = n_channels * (2 ** min(i, 3)) - layers.extend([ - nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), - nn.BatchNorm1d(out_ch), - nn.ReLU(inplace=True), - nn.Dropout1d(dropout), - nn.MaxPool1d(kernel_size=2) - ]) + layers.extend( + [ + nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), + nn.BatchNorm1d(out_ch), + nn.ReLU(inplace=True), + nn.Dropout1d(dropout), + nn.MaxPool1d(kernel_size=2), + ] + ) in_ch = out_ch self.encoder = nn.Sequential(*layers) @@ -61,7 +71,7 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, n_c 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() @@ -70,7 +80,7 @@ def _init_weights(self): """Initialize weights using He (Kaiming) Normal initialization.""" 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') + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.BatchNorm1d): @@ -95,4 +105,3 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: # 3. Regression return self.regressor(pooled) - diff --git a/runs/exp06_1lay_6out/config.py b/runs/exp06_1lay_6out/config.py index 8ba6073..5bde772 100644 --- a/runs/exp06_1lay_6out/config.py +++ b/runs/exp06_1lay_6out/config.py @@ -19,14 +19,7 @@ "sio2_roughness": None, "sio2_sld": None, }, - "simulation": { - "wavelength": 1.54, - "n_samples": int(1e3), - "q_points": 1000, - "q_min": tth2q(0.1), - "q_max": tth2q(15), - "power": 0.95 - }, + "simulation": {"wavelength": 1.54, "n_samples": int(1e3), "q_points": 1000, "q_min": tth2q(0.1), "q_max": tth2q(15), "power": 0.95}, "model": { "n_channels": 32, "depth": 5, @@ -40,11 +33,12 @@ "weight_decay": 0.0005310131070268705, "val_ratio": 0.2, "test_ratio": 0.1, - "patience" : 15, + "patience": 15, "num_workers": 0, }, } + def save_config(config: dict, file: Path | str): """ 딕셔너리를 JSON 파일로 저장합니다. diff --git a/runs/exp06_1lay_6out/dataset.py b/runs/exp06_1lay_6out/dataset.py index 2b65f44..643cf03 100644 --- a/runs/exp06_1lay_6out/dataset.py +++ b/runs/exp06_1lay_6out/dataset.py @@ -13,11 +13,8 @@ class XRRPreprocessor: Shared class for XRR data preprocessing and inverse transformation. (Used by both Dataset and InferenceEngine) """ - def __init__(self, - qs: np.ndarray, - stats_file: Path | str | None = None, - device: torch.device = torch.device('cpu') - ): + + 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 @@ -90,9 +87,16 @@ class XRR1LayerDataset(Dataset): """ def __init__( - self, qs: np.ndarray, h5_file: str | Path, stats_file: str | Path, - mode: str = "train", val_ratio: float = 0.2, test_ratio: float = 0.1, - augment: bool = False, aug_prob: float = 0.5, min_scan_range: float = 0.15 + self, + qs: np.ndarray, + h5_file: str | Path, + stats_file: str | Path, + mode: str = "train", + val_ratio: float = 0.2, + test_ratio: float = 0.1, + augment: bool = False, + aug_prob: float = 0.5, + min_scan_range: float = 0.15, ): self.h5_path = Path(h5_file) @@ -101,7 +105,7 @@ def __init__( # Grid & Augmentation self.target_q = qs - self.augment = augment and (mode == 'train') + self.augment = augment and (mode == "train") self.aug_prob = aug_prob self.min_scan_range = min_scan_range @@ -163,22 +167,22 @@ def _setup_param_stats(self): elif self.mode == "train": print(f"[{self.mode}] Calculating statistics from training data...") train_indices = range(0, self.train_end) - params = np.stack([ - self.thickness[train_indices], - self.roughness[train_indices], - self.sld[train_indices], - self.sio2_thickness[train_indices], - self.sio2_roughness[train_indices], - self.sio2_sld[train_indices], - ], axis=1) + params = np.stack( + [ + self.thickness[train_indices], + self.roughness[train_indices], + self.sld[train_indices], + self.sio2_thickness[train_indices], + self.sio2_roughness[train_indices], + self.sio2_sld[train_indices], + ], + axis=1, + ) self.param_mean = np.mean(params, axis=0) - self.param_std = np.std(params, axis=0) + 1e-8 # Prevent division by zero + self.param_std = np.std(params, axis=0) + 1e-8 # Prevent division by zero - torch.save({ - "param_mean": torch.from_numpy(self.param_mean), - "param_std": torch.from_numpy(self.param_std) - }, self.stats_path) + torch.save({"param_mean": torch.from_numpy(self.param_mean), "param_std": torch.from_numpy(self.param_std)}, self.stats_path) else: raise FileNotFoundError(f"Stats file not found at {self.stats_path}. Run 'train' first.") @@ -200,7 +204,7 @@ def __getitem__(self, idx): def _get_raw_data(self, idx): if self.hf is None: - self.hf = h5py.File(self.h5_path, 'r', swmr=True) + self.hf = h5py.File(self.h5_path, "r", swmr=True) R_raw = self.hf["R"][idx] R_raw = add_noise(R_raw) @@ -209,14 +213,17 @@ def _get_raw_data(self, idx): else: q_raw = self.source_q[idx] - params_raw = np.array([ - self.thickness[idx], - self.roughness[idx], - self.sld[idx], - self.sio2_thickness[idx], - self.sio2_roughness[idx], - self.sio2_sld[idx], - ], dtype=np.float32) + params_raw = np.array( + [ + self.thickness[idx], + self.roughness[idx], + self.sld[idx], + self.sio2_thickness[idx], + self.sio2_roughness[idx], + self.sio2_sld[idx], + ], + dtype=np.float32, + ) return R_raw, q_raw, params_raw diff --git a/runs/exp06_1lay_6out/evaluate.py b/runs/exp06_1lay_6out/evaluate.py index 8c8917b..30cab4d 100644 --- a/runs/exp06_1lay_6out/evaluate.py +++ b/runs/exp06_1lay_6out/evaluate.py @@ -23,7 +23,7 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict[str, np.nd mae = np.mean(abs_errors, axis=0) # RMSE (Root Mean Squared Error) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) # MAPE (Mean Absolute Percentage Error) - avoid division by zero eps = 1e-6 @@ -37,12 +37,7 @@ def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict[str, np.nd } -def generate_results_df( - preds: np.ndarray, - targets: np.ndarray, - errors: np.ndarray, - param_names: list[str] -) -> pd.DataFrame: +def generate_results_df(preds: np.ndarray, targets: np.ndarray, errors: np.ndarray, param_names: list[str]) -> pd.DataFrame: """ Organize raw numpy arrays into a structured Pandas DataFrame. Structure: [Param_True, Param_Pred, Param_Error] for each parameter. @@ -50,7 +45,7 @@ def generate_results_df( data = {} for i, name in enumerate(param_names): # Clean name for CSV headers (remove units) - clean_name = name.split(' (')[0] + clean_name = name.split(" (")[0] data[f"{clean_name}_True"] = targets[:, i] data[f"{clean_name}_Pred"] = preds[:, i] @@ -68,15 +63,11 @@ def save_results_csv(df: pd.DataFrame, save_path: Path) -> None: save_path.parent.mkdir(parents=True, exist_ok=True) # utf-8-sig ensures special characters (Angstrom, Greek letters) open correctly in Excel - df.to_csv(save_path, index=False, encoding='utf-8-sig') + df.to_csv(save_path, index=False, encoding="utf-8-sig") print(f"Saved CSV report: {save_path}") -def save_results_plot( - df: pd.DataFrame, - param_names: list[str], - save_path: Path -) -> None: +def save_results_plot(df: pd.DataFrame, param_names: list[str], save_path: Path) -> None: """ Visualize results from the DataFrame and save as an image. Generates Scatter plots (Pred vs True) and Error Histograms. @@ -88,7 +79,7 @@ def save_results_plot( fig, axes = plt.subplots(2, n_params, figsize=(6 * n_params, 10)) for i, name in enumerate(param_names): - clean_name = name.split(' (')[0] + clean_name = name.split(" (")[0] col_true = f"{clean_name}_True" col_pred = f"{clean_name}_Pred" col_err = f"{clean_name}_Error" @@ -102,8 +93,8 @@ def save_results_plot( min_val = min(np.min(y_true), np.min(y_pred)) max_val = max(np.max(y_true), np.max(y_pred)) - ax_scatter.plot([min_val, max_val], [min_val, max_val], 'r--', lw=2, alpha=0.7, label='Ideal') - ax_scatter.scatter(y_true, y_pred, alpha=0.3, s=10, color='royalblue', label='Data') + ax_scatter.plot([min_val, max_val], [min_val, max_val], "r--", lw=2, alpha=0.7, label="Ideal") + ax_scatter.scatter(y_true, y_pred, alpha=0.3, s=10, color="royalblue", label="Data") ax_scatter.set_title(f"{name}: Pred vs True") ax_scatter.set_xlabel("True Value") @@ -116,9 +107,9 @@ def save_results_plot( mu = np.mean(err_data) sigma = np.std(err_data) - ax_hist.hist(err_data, bins=50, density=True, alpha=0.6, color='seagreen', edgecolor='black') - ax_hist.axvline(0, color='red', linestyle='--', linewidth=1.5) - ax_hist.axvline(mu, color='orange', linestyle=':', linewidth=2, label=f'Mean: {mu:.2f}') + ax_hist.hist(err_data, bins=50, density=True, alpha=0.6, color="seagreen", edgecolor="black") + ax_hist.axvline(0, color="red", linestyle="--", linewidth=1.5) + ax_hist.axvline(mu, color="orange", linestyle=":", linewidth=2, label=f"Mean: {mu:.2f}") ax_hist.set_title(f"{name} Error (Std: {sigma:.2f})") ax_hist.set_xlabel("Error") @@ -139,26 +130,26 @@ def save_history_plot(history: dict, save_path: Path) -> None: if not save_path or not history: return - epochs = range(1, len(history['train']) + 1) + epochs = range(1, len(history["train"]) + 1) 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') - ax1.set_ylabel('Loss (MSE)') - ax1.set_title('Training & Validation Loss') + 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") + ax1.set_ylabel("Loss (MSE)") + ax1.set_title("Training & Validation Loss") 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') - ax2.set_ylabel('Learning Rate') - ax2.set_title('Learning Rate Schedule') - ax2.set_yscale('log') + if "lr" in history and len(history["lr"]) > 0: + ax2.plot(epochs, history["lr"], label="Learning Rate", color="green", linestyle="--") + ax2.set_xlabel("Epochs") + ax2.set_ylabel("Learning Rate") + ax2.set_title("Learning Rate Schedule") + ax2.set_yscale("log") ax2.legend() ax2.grid(True, alpha=0.3, which="both") @@ -199,9 +190,9 @@ def print_metrics_table(metrics: dict[str, np.ndarray], param_names: list[str]) print("-" * 65) for i, name in enumerate(param_names): - mae = metrics['mae'][i] - rmse = metrics['rmse'][i] - mape = metrics['mape'][i] + mae = metrics["mae"][i] + rmse = metrics["rmse"][i] + mape = metrics["mape"][i] print(f"{name:<20} | {mae:<10.4f} | {rmse:<10.4f} | {mape:<10.2f}") print("=" * 65) @@ -213,7 +204,7 @@ def evaluate_pipeline( stats_path: Path, report_img_path: Path | None = None, report_csv_path: Path | None = None, - report_history_path: Path | None = None + report_history_path: Path | None = None, ) -> None: """ Orchestrates the full evaluation process: @@ -225,21 +216,21 @@ def evaluate_pipeline( return # 2. Setup Resources - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Load Statistics - stats = torch.load(stats_path, map_location='cpu') + stats = torch.load(stats_path, map_location="cpu") param_mean = stats["param_mean"] param_std = stats["param_std"] # Load Model ckpt = torch.load(checkpoint_path, map_location=device) - model_args = ckpt.get('config', {}).get('model_args', {}) + model_args = ckpt.get("config", {}).get("model_args", {}) - history = ckpt.get('history', {}) + history = ckpt.get("history", {}) model = XRR1DRegressor(**model_args).to(device) - model.load_state_dict(ckpt['model_state_dict']) + model.load_state_dict(ckpt["model_state_dict"]) print(f"Model loaded: {checkpoint_path.name}") # 3. Inference (Normalized) @@ -258,13 +249,10 @@ def evaluate_pipeline( metrics = calculate_metrics(preds_np, targets_np) # Define parameter names (ensure this matches model output dimension) - param_names = [ - "Thickness (Å)", "Roughness (Å)", "SLD (10⁻⁶ Å⁻²)", - "SiO2 Thickness (Å)", "SiO2 Roughness (Å)", "SiO2 SLD (10⁻⁶ Å⁻²)" - ] + param_names = ["Thickness (Å)", "Roughness (Å)", "SLD (10⁻⁶ Å⁻²)", "SiO2 Thickness (Å)", "SiO2 Roughness (Å)", "SiO2 SLD (10⁻⁶ Å⁻²)"] # Create Master DataFrame - results_df = generate_results_df(preds_np, targets_np, metrics['errors'], param_names) + results_df = generate_results_df(preds_np, targets_np, metrics["errors"], param_names) # 6. Output & Reporting print_metrics_table(metrics, param_names) @@ -282,7 +270,7 @@ def evaluate_pipeline( def main(): # Configuration & Paths if not Path(CONFIG["base_dir"]).exists(): - raise FileNotFoundError(f"Base directory {CONFIG["base_dir"]} does not exist.") + raise FileNotFoundError(f"Base directory {CONFIG['base_dir']} does not exist.") exp_dir = Path(CONFIG["base_dir"]) / CONFIG["exp_name"] h5_file = exp_dir / "dataset.h5" stats_file = exp_dir / "stats.pt" @@ -296,21 +284,17 @@ def main(): print("Dataset file not found. Cannot proceed.") return qs: np.ndarray = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"]) + CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"] + ) # Dataset Preparation test_set = XRR1LayerDataset( - qs, h5_file, stats_file, mode="test", + qs, + h5_file, + stats_file, + mode="test", ) - test_loader = DataLoader( - test_set, - batch_size=CONFIG["training"]["batch_size"], - shuffle=False, - num_workers=0 - ) + test_loader = DataLoader(test_set, batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=0) # Execute Pipeline evaluate_pipeline( @@ -319,7 +303,7 @@ def main(): stats_path=stats_file, report_img_path=report_file_img, report_csv_path=report_file_csv, - report_history_path=report_history_img + report_history_path=report_history_img, ) diff --git a/runs/exp06_1lay_6out/fitting_engine.py b/runs/exp06_1lay_6out/fitting_engine.py index c2ad6a2..7b12d54 100644 --- a/runs/exp06_1lay_6out/fitting_engine.py +++ b/runs/exp06_1lay_6out/fitting_engine.py @@ -14,6 +14,7 @@ class GenXFitter: - Input: Readable SLD values (e.g., 2.07, 18.8) - Internal: Automatically handles physical unit conversion (1e-6 * r_e) """ + def __init__(self, q: np.ndarray, R: np.ndarray, film_params: ParamSet, sio2_params: ParamSet): """ Args: @@ -221,12 +222,12 @@ def plot(self): R_sim = self.model.data[0].y_sim plt.figure(figsize=(8, 6)) - plt.plot(q, R_meas, 'ko', label='Measured', markersize=4, alpha=0.6) - plt.plot(q, R_sim, 'r-', label='GenX Fit', linewidth=2) - plt.yscale('log') - plt.xlabel(r'q [$\AA^{-1}$]') - plt.ylabel('Reflectivity') - plt.title(f'GenX Fit Result (FOM: {self.model.fom:.4e})') + plt.plot(q, R_meas, "ko", label="Measured", markersize=4, alpha=0.6) + plt.plot(q, R_sim, "r-", label="GenX Fit", linewidth=2) + plt.yscale("log") + plt.xlabel(r"q [$\AA^{-1}$]") + plt.ylabel("Reflectivity") + plt.title(f"GenX Fit Result (FOM: {self.model.fom:.4e})") plt.legend() plt.grid(True, alpha=0.3) plt.show() diff --git a/runs/exp06_1lay_6out/inference.py b/runs/exp06_1lay_6out/inference.py index 4404b1c..85538b7 100644 --- a/runs/exp06_1lay_6out/inference.py +++ b/runs/exp06_1lay_6out/inference.py @@ -35,13 +35,7 @@ def __init__(self, exp_dir=None): 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 - ) + 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() @@ -52,17 +46,20 @@ def _load_model(self): ckpt = torch.load(self.checkpoint_file, map_location=self.device) # Get model configuration (fallback to Config if not present) - model_args = ckpt.get('config', {}).get('model_args', { - 'q_len': self.n_points, - 'input_channels': 2, # Fixed: 2 channels (LogR, Mask) - 'n_channels': CONFIG["model"]["n_channels"], - 'depth': CONFIG["model"]["depth"], - 'mlp_hidden': CONFIG["model"]["mlp_hidden"], - }) + model_args = ckpt.get("config", {}).get( + "model_args", + { + "q_len": self.n_points, + "input_channels": 2, # Fixed: 2 channels (LogR, Mask) + "n_channels": CONFIG["model"]["n_channels"], + "depth": CONFIG["model"]["depth"], + "mlp_hidden": CONFIG["model"]["mlp_hidden"], + }, + ) # Initialize and load model self.model = XRR1DRegressor(**model_args).to(self.device) - self.model.load_state_dict(ckpt['model_state_dict']) + self.model.load_state_dict(ckpt["model_state_dict"]) self.model.eval() print(f"[Inference] Model loaded from {self.checkpoint_file}") diff --git a/runs/exp06_1lay_6out/inference_pipeline.py b/runs/exp06_1lay_6out/inference_pipeline.py index d67edc3..d47804a 100644 --- a/runs/exp06_1lay_6out/inference_pipeline.py +++ b/runs/exp06_1lay_6out/inference_pipeline.py @@ -9,11 +9,7 @@ def run_xrr_analysis( - data_path: str | Path, - weights_dir: str | Path, - inference_engine: XRRInferenceEngine | None = None, - verbose: bool = True, - show_plot: bool = True + data_path: str | Path, weights_dir: str | Path, inference_engine: XRRInferenceEngine | None = None, verbose: bool = True, show_plot: bool = True ) -> dict: """ 단일 XRR 데이터 파일에 대해 [로드 -> NN 추론 -> GenX 피팅] 파이프라인을 실행합니다. @@ -41,9 +37,9 @@ def run_xrr_analysis( weights_dir = Path(weights_dir) if verbose: - print("\n" + "="*60) + print("\n" + "=" * 60) print(f"🚀 XRR Analysis Pipeline: {data_path.name}") - print("="*60) + print("=" * 60) # --------------------------------------------------------- # 1. 데이터 로드 (Data Loading) @@ -99,25 +95,19 @@ def run_xrr_analysis( # 4. 결과 정리 및 시각화 # --------------------------------------------------------- if verbose: - print("\n" + "-"*40) + print("\n" + "-" * 40) print("FINAL ANALYSIS RESULT") - print("-"*40) + print("-" * 40) for param_name, value in final_results.items(): print(f"{param_name:15s}: {value:.4f}") - print("="*40) + print("=" * 40) 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 - } + 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) @@ -129,12 +119,7 @@ def main(): try: # 함수 호출 - result = run_xrr_analysis( - data_path=target_file, - weights_dir=weights_path, - verbose=True, - show_plot=True - ) + 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} Å") diff --git a/runs/exp06_1lay_6out/main.py b/runs/exp06_1lay_6out/main.py index 5ee1593..64523ee 100644 --- a/runs/exp06_1lay_6out/main.py +++ b/runs/exp06_1lay_6out/main.py @@ -51,38 +51,26 @@ def get_dataloaders(qs: np.ndarray, config: dict, h5_file: Path, stats_file: Pat "test_ratio": config["training"]["test_ratio"], "augment": True, "aug_prob": 0.5, - "min_scan_range": 0.15 + "min_scan_range": 0.15, } # Create Dataset instances train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") - val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") - test_set = XRR1LayerDataset(**dataset_kwargs, mode="test") + val_set = XRR1LayerDataset(**dataset_kwargs, mode="val") + test_set = XRR1LayerDataset(**dataset_kwargs, mode="test") # Create DataLoaders batch_size = config["training"]["batch_size"] num_workers = config["training"]["num_workers"] train_loader = DataLoader( - train_set, - batch_size=batch_size, - shuffle=True, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + train_set, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) val_loader = DataLoader( - val_set, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + val_set, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) test_loader = DataLoader( - test_set, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - pin_memory=True if torch.cuda.is_available() else False + test_set, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True if torch.cuda.is_available() else False ) print(f"Dataset sizes: Train={len(train_set)}, Val={len(val_set)}, Test={len(test_set)}") @@ -92,7 +80,7 @@ def get_dataloaders(qs: np.ndarray, config: dict, h5_file: Path, stats_file: Pat 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.") + raise FileNotFoundError(f"Base directory {CONFIG['base_dir']} does not exist.") # 1. Setup and Path Preparation set_seed(42) @@ -107,10 +95,8 @@ def main(): report_history_img = exp_dir / "training_history.png" config_file_json = exp_dir / "config.json" qs: np.ndarray = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"]) + CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"] + ) save_config(CONFIG, config_file_json) print(f"Config file saved at '{config_file_json}'") # 2. Data Preparation @@ -142,16 +128,16 @@ def main(): save_dir=exp_dir, lr=CONFIG["training"]["lr"], weight_decay=CONFIG["training"]["weight_decay"], - patience=CONFIG["training"]["patience"] + patience=CONFIG["training"]["patience"], ) print("Starting training...") trainer.train(CONFIG["training"]["epochs"]) # 6. Final Evaluation - print("\n" + "="*50) + print("\n" + "=" * 50) print("Performing Final Test Evaluation") - print("="*50) + print("=" * 50) if not checkpoint_file.exists(): print(f"No checkpoint found at {checkpoint_file}. Skipping evaluation.") @@ -163,7 +149,7 @@ def main(): stats_path=stats_file, report_img_path=report_file_img, report_csv_path=report_file_csv, - report_history_path=report_history_img + report_history_path=report_history_img, ) diff --git a/runs/exp06_1lay_6out/notebooks/inference_test.ipynb b/runs/exp06_1lay_6out/notebooks/inference_test.ipynb index f31e507..62e195e 100644 --- a/runs/exp06_1lay_6out/notebooks/inference_test.ipynb +++ b/runs/exp06_1lay_6out/notebooks/inference_test.ipynb @@ -154,23 +154,23 @@ "\n", "# 피팅 실행\n", "final_results = fitter.run(verbose=True)\n", - " # ---------------------------------------------------------\n", + "# ---------------------------------------------------------\n", "\n", - "print(\"\\n\" + \"-\"*40)\n", + "print(\"\\n\" + \"-\" * 40)\n", "print(\"FINAL ANALYSIS RESULT\")\n", - "print(\"-\"*40)\n", + "print(\"-\" * 40)\n", "for param_name, value in final_results.items():\n", " print(f\"{param_name:15s}: {value:.4f}\")\n", - "print(\"=\"*40)\n", + "print(\"=\" * 40)\n", "\n", "\n", "fitter.plot()\n", "\n", "# 결과 반환\n", "result = {\n", - "\"nn_preds\": (pred_f_d, pred_f_sig, pred_f_sld),\n", - "\"final_params\": final_results,\n", - "\"fitter\": fitter,\n", + " \"nn_preds\": (pred_f_d, pred_f_sig, pred_f_sld),\n", + " \"final_params\": final_results,\n", + " \"fitter\": fitter,\n", "}\n", "\n", "for key, val in result.items():\n", diff --git a/runs/exp06_1lay_6out/simulate.py b/runs/exp06_1lay_6out/simulate.py index d9bb7a5..a4432e9 100644 --- a/runs/exp06_1lay_6out/simulate.py +++ b/runs/exp06_1lay_6out/simulate.py @@ -22,12 +22,7 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): 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 - } + simulator_args: dict = {"qs": qs, "n_layers": 1, "n_samples": simulation["n_samples"], "has_noise": False} if param_ranges["thickness"] is not None: simulator_args["thickness_range"] = param_ranges["thickness"] if param_ranges["roughness"] is not None: @@ -40,9 +35,7 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): simulator_args["sio2_roughness_range"] = param_ranges["sio2_roughness"] if param_ranges["sio2_sld"] is not None: simulator_args["sio2_sld_range"] = param_ranges["sio2_sld"] - simulator = XRRSimulator( - **simulator_args - ) + simulator = XRRSimulator(**simulator_args) simulator.save_hdf5(h5_file, show_progress=True) @@ -62,8 +55,6 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): h5_file = exp_dir / "dataset.h5" qs: np.ndarray = powerspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"], - CONFIG["simulation"]["power"]) + CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"], CONFIG["simulation"]["power"] + ) generate_1layer_data(qs, CONFIG, h5_file) diff --git a/runs/exp06_1lay_6out/train.py b/runs/exp06_1lay_6out/train.py index 3b87067..43b799b 100644 --- a/runs/exp06_1lay_6out/train.py +++ b/runs/exp06_1lay_6out/train.py @@ -13,15 +13,19 @@ class Trainer: Trainer class for XRR 1D Regressor. Handles training loop, validation, checkpointing, and early stopping. """ - def __init__(self, - model: nn.Module, train_loader: DataLoader, + + def __init__( + self, + model: nn.Module, + train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, - lr: float = 1e-3, weight_decay: float = 1e-4, - patience: int = 15 + lr: float = 1e-3, + weight_decay: float = 1e-4, + patience: int = 15, ): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = model.to(self.device) self.train_loader = train_loader @@ -39,24 +43,22 @@ def __init__(self, # Scheduler: Reduce Learning Rate when validation loss plateaus # Note: 'verbose' argument is deprecated in newer PyTorch versions, removed here. - self.scheduler = ReduceLROnPlateau( - self.optimizer, mode='min', patience=5, factor=0.5 - ) + self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=5, factor=0.5) # Training State - self.best_val_loss = float('inf') + self.best_val_loss = float("inf") self.patience_counter = 0 self.early_stop_patience = patience self.history = {"train": [], "val": [], "lr": []} - self.scaler = torch.amp.GradScaler('cuda') + self.scaler = torch.amp.GradScaler("cuda") print(f"🚀 Trainer initialized on {self.device}") def _train_epoch(self, epoch: int, total_epochs: int) -> float: """Runs a single training epoch.""" - if self.device.type == 'cuda': + if self.device.type == "cuda": torch.cuda.empty_cache() self.model.train() @@ -72,7 +74,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.optimizer.zero_grad(set_to_none=True) # Forward pass - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): preds = self.model(inputs) loss = self.criterion(preds, targets) @@ -88,7 +90,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.scaler.update() running_loss += loss.item() - pbar.set_postfix({'loss': f'{loss.item():.5f}'}) + pbar.set_postfix({"loss": f"{loss.item():.5f}"}) avg_loss = running_loss / len(self.train_loader) return avg_loss @@ -130,15 +132,15 @@ def train(self, epochs: int, resume_from: str | Path | None = None): val_loss = self._validate() # Update History - self.history['train'].append(train_loss) - self.history['val'].append(val_loss) - self.history['lr'].append(self.optimizer.param_groups[0]['lr']) + self.history["train"].append(train_loss) + self.history["val"].append(val_loss) + self.history["lr"].append(self.optimizer.param_groups[0]["lr"]) # Scheduler Step self.scheduler.step(val_loss) # Log current Learning Rate - current_lr = self.optimizer.param_groups[0]['lr'] + current_lr = self.optimizer.param_groups[0]["lr"] print(f"Epoch {epoch:03d} | Train Loss: {train_loss:.6f} | Val Loss: {val_loss:.6f} | LR: {current_lr:.2e}") @@ -166,20 +168,21 @@ def _save_checkpoint(self, filename: str, epoch: int, val_loss: float): path = self.checkpoint_dir / filename # Save model configuration for easier inference later - model_config = self.model.config if hasattr(self.model, 'config') else {} - - torch.save({ - 'epoch': epoch, - 'model_state_dict': self.model.state_dict(), - 'optimizer_state_dict': self.optimizer.state_dict(), - 'scheduler_state_dict': self.scheduler.state_dict(), - 'val_loss': val_loss, - 'best_val_loss': self.best_val_loss, - 'history': self.history, - 'config': { - 'model_args': model_config - } - }, path) + model_config = self.model.config if hasattr(self.model, "config") else {} + + torch.save( + { + "epoch": epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "scheduler_state_dict": self.scheduler.state_dict(), + "val_loss": val_loss, + "best_val_loss": self.best_val_loss, + "history": self.history, + "config": {"model_args": model_config}, + }, + path, + ) def _load_checkpoint(self, filepath: str | Path) -> int: """Loads a checkpoint to resume training.""" @@ -190,19 +193,19 @@ def _load_checkpoint(self, filepath: str | Path) -> int: checkpoint = torch.load(filepath, map_location=self.device) - self.model.load_state_dict(checkpoint['model_state_dict']) + 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']) - if 'scaler_state_dict' in checkpoint: - self.scaler.load_state_dict(checkpoint['scaler_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"]) + if "scaler_state_dict" in checkpoint: + self.scaler.load_state_dict(checkpoint["scaler_state_dict"]) - self.history = checkpoint.get('history', {'train': [], 'val': [], 'lr': []}) + self.history = checkpoint.get("history", {"train": [], "val": [], "lr": []}) - self.best_val_loss = checkpoint.get('best_val_loss', float('inf')) - start_epoch = checkpoint.get('epoch', 0) + 1 + self.best_val_loss = checkpoint.get("best_val_loss", float("inf")) + start_epoch = checkpoint.get("epoch", 0) + 1 print(f"✅ Checkpoint loaded: {filepath} (Resuming from Epoch {start_epoch})") return start_epoch diff --git a/runs/exp06_1lay_6out/tune.py b/runs/exp06_1lay_6out/tune.py index 79a5633..8394407 100644 --- a/runs/exp06_1lay_6out/tune.py +++ b/runs/exp06_1lay_6out/tune.py @@ -16,10 +16,11 @@ # --------------------------------------------------------- # 1. Settings # --------------------------------------------------------- -N_TRIALS = 50 # Total number of trials +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" -STORAGE_URL = "sqlite:///xrr_tuning.db" # Saves results to a database file +STORAGE_URL = "sqlite:///xrr_tuning.db" # Saves results to a database file + def get_dataloaders(batch_size): """ @@ -40,7 +41,7 @@ def get_dataloaders(batch_size): "n_points": CONFIG["simulation"]["q_points"], "augment": True, "aug_prob": 0.5, - "min_scan_range": 0.15 + "min_scan_range": 0.15, } train_set = XRR1LayerDataset(**dataset_kwargs, mode="train") @@ -52,6 +53,7 @@ def get_dataloaders(batch_size): return train_loader, val_loader + def objective(trial): # --------------------------------------------------------- # 2. Define Hyperparameter Search Space @@ -65,7 +67,7 @@ def objective(trial): # Optimization lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True) 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 + batch_size = trial.suggest_categorical("batch_size", [32, 64, 128]) # Keep max low to avoid OOM # --------------------------------------------------------- # 3. Setup (Model, Data, Optimizer) @@ -85,7 +87,7 @@ def objective(trial): n_channels=n_channels, depth=depth, mlp_hidden=mlp_hidden, - dropout=dropout + dropout=dropout, ).to(device) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay) @@ -104,7 +106,7 @@ def objective(trial): optimizer.zero_grad(set_to_none=True) - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): preds = model(inputs) loss = criterion(preds, targets) @@ -132,12 +134,13 @@ def objective(trial): # Handle Pruning (if the trial is hopeless, stop it) if trial.should_prune(): - print(f"[Trial {trial.number}] Pruned at epoch {epoch+1} (Loss: {avg_val_loss:.4f})") + print(f"[Trial {trial.number}] Pruned at epoch {epoch + 1} (Loss: {avg_val_loss:.4f})") raise optuna.TrialPruned() # Return the metric to minimize return avg_val_loss + def save_best_config(study): """Saves the best hyperparameters to a JSON file.""" best_params = study.best_params @@ -152,6 +155,7 @@ def save_best_config(study): json.dump(best_params, f, indent=4) print(f"\nSaved best parameters to: {save_path}") + if __name__ == "__main__": # Clear memory before starting gc.collect() @@ -161,13 +165,7 @@ def save_best_config(study): pruner = optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=3) # Create Study - study = optuna.create_study( - study_name=STUDY_NAME, - storage=STORAGE_URL, - direction="minimize", - pruner=pruner, - load_if_exists=True - ) + study = optuna.create_study(study_name=STUDY_NAME, storage=STORAGE_URL, direction="minimize", pruner=pruner, load_if_exists=True) print(f"Starting optimization with {N_TRIALS} trials...") diff --git a/runs/exp06_1lay_6out/xrr_model.py b/runs/exp06_1lay_6out/xrr_model.py index 5a43165..f4db7e5 100644 --- a/runs/exp06_1lay_6out/xrr_model.py +++ b/runs/exp06_1lay_6out/xrr_model.py @@ -3,8 +3,16 @@ class XRR1DRegressor(nn.Module): - def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 6, n_channels: int = 64, depth: int = 4, - mlp_hidden: int = 256, dropout: float = 0.1): + def __init__( + self, + q_len: int, + input_channels: int = 2, + output_dim: int = 6, + n_channels: int = 64, + depth: int = 4, + mlp_hidden: int = 256, + dropout: float = 0.1, + ): """ XRR 1D CNN Regressor @@ -19,13 +27,13 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 6, n_c """ super().__init__() self.config = { - 'q_len': q_len, - 'input_channels': input_channels, - 'output_dim': output_dim, - 'n_channels': n_channels, - 'depth': depth, - 'mlp_hidden': mlp_hidden, - 'dropout': dropout, + "q_len": q_len, + "input_channels": input_channels, + "output_dim": output_dim, + "n_channels": n_channels, + "depth": depth, + "mlp_hidden": mlp_hidden, + "dropout": dropout, } # --------------------------------------------------------- @@ -36,13 +44,15 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 6, n_c for i in range(depth): out_ch = n_channels * (2 ** min(i, 3)) - layers.extend([ - nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), - nn.BatchNorm1d(out_ch), - nn.ReLU(inplace=True), - nn.Dropout1d(dropout), - nn.MaxPool1d(kernel_size=2) - ]) + layers.extend( + [ + nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3, bias=False), + nn.BatchNorm1d(out_ch), + nn.ReLU(inplace=True), + nn.Dropout1d(dropout), + nn.MaxPool1d(kernel_size=2), + ] + ) in_ch = out_ch self.encoder = nn.Sequential(*layers) @@ -61,7 +71,7 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 6, n_c 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() @@ -70,7 +80,7 @@ def _init_weights(self): """Initialize weights using He (Kaiming) Normal initialization.""" 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') + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.BatchNorm1d): @@ -95,4 +105,3 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: # 3. Regression return self.regressor(pooled) - diff --git a/runs/exp07_fourier_hires/ablation.py b/runs/exp07_fourier_hires/ablation.py index 6e3e951..fc3fe29 100644 --- a/runs/exp07_fourier_hires/ablation.py +++ b/runs/exp07_fourier_hires/ablation.py @@ -85,9 +85,15 @@ def build_qs(sim_cfg: dict) -> np.ndarray: return np.linspace(sim_cfg["q_min"], sim_cfg["q_max"], sim_cfg["q_points"]).astype(np.float32) -def make_loaders(qs: np.ndarray, cfg: dict, h5_file: Path, stats_file: Path, - modes: list[str] | None = None, augment_eval: bool | None = None, - num_workers: int | None = None): +def make_loaders( + qs: np.ndarray, + cfg: dict, + h5_file: Path, + stats_file: Path, + modes: list[str] | None = None, + augment_eval: bool | None = None, + num_workers: int | None = None, +): t_cfg = cfg["training"] common = { "qs": qs, @@ -111,19 +117,18 @@ def make_loaders(qs: np.ndarray, cfg: dict, h5_file: Path, stats_file: Path, loaders = [] for mode in modes: - ds = XRR1LayerDataset( - **common, mode=mode, - val_ratio=t_cfg["val_ratio"], test_ratio=t_cfg["test_ratio"] - ) + ds = XRR1LayerDataset(**common, mode=mode, val_ratio=t_cfg["val_ratio"], test_ratio=t_cfg["test_ratio"]) workers = t_cfg["num_workers"] if num_workers is None else num_workers - loaders.append(DataLoader( - ds, - batch_size=t_cfg["batch_size"], - shuffle=(mode == "train"), - num_workers=workers, - pin_memory=torch.cuda.is_available(), - drop_last=(mode == "train"), - )) + loaders.append( + DataLoader( + ds, + batch_size=t_cfg["batch_size"], + shuffle=(mode == "train"), + num_workers=workers, + pin_memory=torch.cuda.is_available(), + drop_last=(mode == "train"), + ) + ) return loaders @@ -146,8 +151,7 @@ def steps_per_epoch(loader: DataLoader) -> int: return len(loader) -def resolve_epochs(cfg: dict, train_loader: DataLoader, budget_steps: int | None, - epochs_override: int | None) -> 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 수를 맞춰야 공정 비교가 된다. @@ -164,8 +168,7 @@ def resolve_epochs(cfg: dict, train_loader: DataLoader, budget_steps: int | None return max(1, math.ceil(budget_steps / spe)) -def resolve_patience(cfg: dict, train_loader: DataLoader, budget_steps: int | None, - baseline_spe: int | None) -> int: +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 없음)가 훨씬 이른 @@ -179,9 +182,9 @@ def resolve_patience(cfg: dict, train_loader: DataLoader, budget_steps: int | No return max(1, math.ceil(patience * baseline_spe / spe)) -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: +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로 평가만 수행. @@ -198,7 +201,10 @@ def eval_variant(name: str, cfg: dict, var_dir: Path, h5_file: Path, stats_file: return {"variant": name, "error": "missing checkpoint"} (test_loader,) = make_loaders( - qs, cfg, h5_file, stats_file, + qs, + cfg, + h5_file, + stats_file, modes=["test"], augment_eval=augment_eval, num_workers=0 if augment_eval else None, @@ -213,7 +219,10 @@ def eval_variant(name: str, cfg: dict, var_dir: Path, h5_file: Path, stats_file: print(f" test samples : {len(test_loader.dataset):,}") metrics = evaluate_pipeline( - test_loader, checkpoint_file, stats_file, qs, + test_loader, + checkpoint_file, + stats_file, + qs, report_img_path=var_dir / f"evaluation_correlation_{tag}.png", report_csv_path=var_dir / f"evaluation_results_{tag}.csv", report_history_path=None, @@ -239,11 +248,18 @@ def eval_variant(name: str, cfg: dict, var_dir: Path, h5_file: Path, stats_file: return row -def run_variant(name: str, cfg: dict, root_dir: Path, h5_file: Path, - budget_steps: int | None, epochs_override: int | None, - baseline_spe: int | None = None, - eval_only: bool = False, augment_eval: bool = False, - tag: str = "clean") -> dict: +def run_variant( + name: str, + cfg: dict, + root_dir: Path, + h5_file: Path, + budget_steps: int | None, + epochs_override: int | None, + baseline_spe: int | None = None, + eval_only: bool = False, + augment_eval: bool = False, + tag: str = "clean", +) -> dict: print("\n" + "=" * 80) print(f"[Ablation] Variant: {name}") print("=" * 80) @@ -258,8 +274,7 @@ def run_variant(name: str, cfg: dict, root_dir: Path, h5_file: Path, qs = build_qs(cfg["simulation"]) if eval_only: - return eval_variant(name, cfg, var_dir, h5_file, stats_file, qs, - checkpoint_file, augment_eval, tag) + return eval_variant(name, cfg, var_dir, h5_file, stats_file, qs, checkpoint_file, augment_eval, tag) np.save(var_dir / "qs.npy", qs) save_config(cfg, var_dir / "config.json") @@ -282,7 +297,10 @@ def run_variant(name: str, cfg: dict, root_dir: Path, h5_file: Path, print(f" patience : {patience} epochs (step 환산)") trainer = Trainer( - model, train_loader, val_loader, var_dir, + model, + train_loader, + val_loader, + var_dir, lr=cfg["training"]["lr"], weight_decay=cfg["training"]["weight_decay"], patience=patience, @@ -298,17 +316,26 @@ def run_variant(name: str, cfg: dict, root_dir: Path, h5_file: Path, if time_log.exists(): prev_seconds = float(json.loads(time_log.read_text()).get("cumulative_seconds", 0.0)) cumulative_seconds = prev_seconds + train_seconds - time_log.write_text(json.dumps({ - "cumulative_seconds": cumulative_seconds, - "last_run_seconds": train_seconds, - }, indent=2), encoding="utf-8") + time_log.write_text( + json.dumps( + { + "cumulative_seconds": cumulative_seconds, + "last_run_seconds": train_seconds, + }, + indent=2, + ), + encoding="utf-8", + ) val_hist = trainer.history.get("val", []) best_epoch = int(np.argmin(val_hist)) + 1 if val_hist else None epochs_ran = len(val_hist) metrics = evaluate_pipeline( - test_loader, checkpoint_file, stats_file, qs, + test_loader, + checkpoint_file, + stats_file, + qs, report_img_path=var_dir / "evaluation_correlation.png", report_csv_path=var_dir / "evaluation_results.csv", report_history_path=var_dir / "training_history.png", @@ -361,13 +388,9 @@ def merge_summary(rows: list[dict], out_csv: Path) -> pd.DataFrame: df_new = pd.concat([kept, df_new], ignore_index=True) order = {name: i for i, name in enumerate(VARIANTS)} - df_new = (df_new - .sort_values("variant", key=lambda s: s.map(order).fillna(len(order))) - .reset_index(drop=True)) + df_new = df_new.sort_values("variant", key=lambda s: s.map(order).fillna(len(order))).reset_index(drop=True) df_new.to_csv(out_csv, index=False, encoding="utf-8-sig") - out_csv.with_suffix(".json").write_text( - df_new.to_json(orient="records", indent=2, force_ascii=False), encoding="utf-8" - ) + out_csv.with_suffix(".json").write_text(df_new.to_json(orient="records", indent=2, force_ascii=False), encoding="utf-8") return df_new @@ -379,41 +402,54 @@ def print_plan(cfgs: dict[str, dict]): qn = cfg["simulation"]["q_points"] model = build_model(cfg, q_len=qn) n = sum(p.numel() for p in model.parameters()) - print(f"{name:<16} | {qn:>6} | {cfg['model']['depth']:>5} | " - f"{str(cfg['model']['use_fourier']):>7} | {str(cfg['training']['augment']):>7} | " - f"{cfg['training']['expand_factor']:>6} | {n:>10,}") + print( + f"{name:<16} | {qn:>6} | {cfg['model']['depth']:>5} | " + f"{str(cfg['model']['use_fourier']):>7} | {str(cfg['training']['augment']):>7} | " + f"{cfg['training']['expand_factor']:>6} | {n:>10,}" + ) print("=" * 100) def main(): parser = argparse.ArgumentParser(description="exp07 ablation runner") - parser.add_argument("--only", nargs="+", default=None, - help=f"실행할 variant 이름 (기본: 전체). 선택지: {list(VARIANTS)}") - parser.add_argument("--epochs", type=int, default=None, - help="모든 variant의 epoch를 강제 지정. **스모크 테스트 전용** - " - "variant마다 epoch당 step 수가 달라 공정 비교가 깨진다. " - "예산을 줄이려면 --baseline-epochs를 쓸 것") - parser.add_argument("--baseline-epochs", type=int, default=None, - help="기준 variant 기준 epoch 수. 이 값으로 총 step 예산을 정하고 " - "나머지 variant는 같은 step 수에 맞춘다 (기본: config의 epochs)") - parser.add_argument("--budget-steps", type=int, default=None, - help="총 optimizer step 예산을 직접 지정 (--baseline-epochs보다 우선)") - parser.add_argument("--expand-factor", type=int, default=None, - help="augmented variant의 expand_factor 덮어쓰기. 학습 내용이 아니라 " - "epoch 경계(=val/checkpoint 주기)만 바꾼다. 낮추면 같은 step 예산에서 " - "학습곡선 해상도가 올라간다") - parser.add_argument("--budget-mode", choices=["steps", "epochs"], default="steps", - help="steps: 기준 variant의 총 optimizer step 수에 맞춰 epoch 자동 조정 (기본). " - "epochs: config의 epochs를 그대로 사용") - parser.add_argument("--baseline", default="A_full", - help="step 예산 기준이 되는 variant") - parser.add_argument("--dry-run", action="store_true", - help="학습 없이 실행 계획만 출력") - parser.add_argument("--eval-only", action="store_true", - help="학습을 건너뛰고 저장된 best.pt로 평가만 수행") - parser.add_argument("--augment-eval", action="store_true", - help="측정 노이즈가 실린 test set에서 평가. clean 평가는 augmentation " - "없이 학습한 variant에 유리하게 편향되므로 두 도메인 모두 측정할 것") + parser.add_argument("--only", nargs="+", default=None, help=f"실행할 variant 이름 (기본: 전체). 선택지: {list(VARIANTS)}") + parser.add_argument( + "--epochs", + type=int, + default=None, + help="모든 variant의 epoch를 강제 지정. **스모크 테스트 전용** - " + "variant마다 epoch당 step 수가 달라 공정 비교가 깨진다. " + "예산을 줄이려면 --baseline-epochs를 쓸 것", + ) + parser.add_argument( + "--baseline-epochs", + type=int, + default=None, + help="기준 variant 기준 epoch 수. 이 값으로 총 step 예산을 정하고 나머지 variant는 같은 step 수에 맞춘다 (기본: config의 epochs)", + ) + parser.add_argument("--budget-steps", type=int, default=None, help="총 optimizer step 예산을 직접 지정 (--baseline-epochs보다 우선)") + parser.add_argument( + "--expand-factor", + type=int, + default=None, + help="augmented variant의 expand_factor 덮어쓰기. 학습 내용이 아니라 " + "epoch 경계(=val/checkpoint 주기)만 바꾼다. 낮추면 같은 step 예산에서 " + "학습곡선 해상도가 올라간다", + ) + parser.add_argument( + "--budget-mode", + choices=["steps", "epochs"], + default="steps", + help="steps: 기준 variant의 총 optimizer step 수에 맞춰 epoch 자동 조정 (기본). epochs: config의 epochs를 그대로 사용", + ) + parser.add_argument("--baseline", default="A_full", help="step 예산 기준이 되는 variant") + parser.add_argument("--dry-run", action="store_true", help="학습 없이 실행 계획만 출력") + parser.add_argument("--eval-only", action="store_true", help="학습을 건너뛰고 저장된 best.pt로 평가만 수행") + parser.add_argument( + "--augment-eval", + action="store_true", + help="측정 노이즈가 실린 test set에서 평가. clean 평가는 augmentation 없이 학습한 variant에 유리하게 편향되므로 두 도메인 모두 측정할 것", + ) args = parser.parse_args() names = args.only if args.only else list(VARIANTS) @@ -460,23 +496,26 @@ def main(): base_stats = root_dir / "ablation" / args.baseline / "stats.pt" base_stats.parent.mkdir(parents=True, exist_ok=True) base_loader, _, _ = make_loaders( - build_qs(base_cfg["simulation"]), base_cfg, h5_file, base_stats, + build_qs(base_cfg["simulation"]), + base_cfg, + h5_file, + base_stats, ) baseline_spe = steps_per_epoch(base_loader) if args.budget_steps is not None: budget_steps = args.budget_steps - print(f"\n[Budget] 직접 지정 = {budget_steps:,} steps " - f"(baseline={args.baseline}, {baseline_spe:,} steps/epoch)") + print(f"\n[Budget] 직접 지정 = {budget_steps:,} steps (baseline={args.baseline}, {baseline_spe:,} steps/epoch)") else: base_epochs = args.baseline_epochs or base_cfg["training"]["epochs"] budget_steps = base_epochs * baseline_spe - print(f"\n[Budget] baseline={args.baseline}, {base_epochs} epochs × " - f"{baseline_spe:,} steps = {budget_steps:,} steps " - f"(모든 variant를 이 step 수에 맞춤)") + print( + f"\n[Budget] baseline={args.baseline}, {base_epochs} epochs × " + f"{baseline_spe:,} steps = {budget_steps:,} steps " + f"(모든 variant를 이 step 수에 맞춤)" + ) elif args.epochs is not None: - print(f"\n⚠️ --epochs {args.epochs}: variant마다 epoch당 step 수가 다르므로 " - f"공정 비교가 성립하지 않음 (스모크 테스트 전용)") + print(f"\n⚠️ --epochs {args.epochs}: variant마다 epoch당 step 수가 다르므로 공정 비교가 성립하지 않음 (스모크 테스트 전용)") tag = "augmented" if args.augment_eval else "clean" suffix = f"_{tag}" if args.eval_only else "" @@ -486,10 +525,18 @@ def main(): out_csv.parent.mkdir(parents=True, exist_ok=True) for name in names: - row = run_variant(name, cfgs[name], root_dir, h5_file, budget_steps, - args.epochs, baseline_spe, - eval_only=args.eval_only, augment_eval=args.augment_eval, - tag=tag) + row = run_variant( + name, + cfgs[name], + root_dir, + h5_file, + budget_steps, + args.epochs, + baseline_spe, + eval_only=args.eval_only, + augment_eval=args.augment_eval, + tag=tag, + ) rows.append(row) # 매 variant마다 중간 저장 (중단 대비) @@ -499,8 +546,7 @@ def main(): print("\n" + "=" * 100) print(f"ABLATION SUMMARY ({tag} test set)" if args.eval_only else "ABLATION SUMMARY") print("=" * 100) - cols = ["variant", "eval_domain", "q_points", "use_fourier", "augment", "params", - "Thickness_MAE", "Roughness_MAE", "SLD_MAE", "Thickness_R2"] + 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와 함께 이미 기록했다 diff --git a/runs/exp07_fourier_hires/analyze.py b/runs/exp07_fourier_hires/analyze.py index c0f7658..30a7b1e 100644 --- a/runs/exp07_fourier_hires/analyze.py +++ b/runs/exp07_fourier_hires/analyze.py @@ -30,18 +30,20 @@ def generate_test_data(self, thick=350.0, rough=5.0, sld=15.0) -> tuple[np.ndarr # 시뮬레이션 (약간의 노이즈 추가 가능) 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 - def analyze(self, - q: np.ndarray, - R: np.ndarray, - run_fitting: bool = True, - save_results: bool = False, - filename: str = "analysis_result", - true_params: ParamSet | None = None): + def analyze( + self, + q: np.ndarray, + R: np.ndarray, + run_fitting: bool = True, + save_results: bool = False, + filename: str = "analysis_result", + true_params: ParamSet | None = None, + ): """ 통합 분석 파이프라인 - q, R: 입력 데이터 @@ -67,16 +69,12 @@ def analyze(self, # 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 - ) + fit_config = XRRConfig(thickness_search_ratio=(0.8, 1.2), steps_structure=400, steps_fine=600) fitter = GenXFitter(q, R, ai_guess, config=fit_config) res = fitter.run(verbose=False) - final_params = ParamSet(res['set_f_d'], res['set_f_sig'], res['set_f_sld']) - R_fit = res['R_sim'] + final_params = ParamSet(res["set_f_d"], res["set_f_sig"], res["set_f_sld"]) + R_fit = res["R_sim"] print(f" [Final Fit] {final_params}") # 4. Final Comparison Visualization @@ -87,26 +85,26 @@ def analyze(self, 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') + 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}Å)') + 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}Å)') + 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}%") + 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}%") - plt.yscale('log') - plt.xlabel(r'$q$ ($\AA^{-1}$)') - plt.ylabel('Reflectivity') + plt.yscale("log") + plt.xlabel(r"$q$ ($\AA^{-1}$)") + plt.ylabel("Reflectivity") plt.title(f"XRR Final Analysis: {filename}") - plt.legend(loc='upper right') - plt.grid(True, which='both', alpha=0.2) + plt.legend(loc="upper right") + plt.grid(True, which="both", alpha=0.2) if save_results: save_path = self.exp_dir / f"final_{filename}.png" @@ -115,6 +113,7 @@ def _visualize_final(self, q, R, ai_p, final_p, R_fit, filename, save_results, t plt.show() + # ----------------------------------------------------------------------------- # Execution Block # ----------------------------------------------------------------------------- @@ -126,11 +125,14 @@ def _visualize_final(self, q, R, ai_p, final_p, R_fit, filename, save_results, t # --- [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, # 테스트 시 저장 안 함 - filename="synthetic_test", - true_params=gt_params) + analyzer.analyze( + q_syn, + R_syn, + run_fitting=True, + save_results=False, # 테스트 시 저장 안 함 + filename="synthetic_test", + true_params=gt_params, + ) # --- [MODE 2] 실측 파일 로드해서 분석 --- # print("\n>>> Running Mode 2: Real Data Analysis") diff --git a/runs/exp07_fourier_hires/augmentations.py b/runs/exp07_fourier_hires/augmentations.py index 5b95807..6a0a831 100644 --- a/runs/exp07_fourier_hires/augmentations.py +++ b/runs/exp07_fourier_hires/augmentations.py @@ -16,7 +16,7 @@ def __init__( prob: float = 0.95, beam_width_range: tuple = (0.005, 0.015), sample_len_range: tuple = (5, 30.0), - do_footprint: bool = True + do_footprint: bool = True, ): self.intensity_noise_scale = intensity_noise_scale self.bg_range = bg_range diff --git a/runs/exp07_fourier_hires/config.py b/runs/exp07_fourier_hires/config.py index 4c62e3e..3934cb7 100644 --- a/runs/exp07_fourier_hires/config.py +++ b/runs/exp07_fourier_hires/config.py @@ -8,7 +8,6 @@ CONFIG = { "exp_name": "1230", "base_dir": Path(r"C:\WorkSpace\05_Resources\Data\XRR_AI\exp07"), - "param_ranges": { "thickness": (10.0, 1200.0), "roughness": (0.0, 15.0), @@ -41,14 +40,12 @@ "test_ratio": 0.1, "patience": 50, "num_workers": 4, - "augment": True, "expand_factor": 50, "aug_prob": 0.9, "intensity_scale": 0.25, "q_shift_sigma": 0.004, "res_sigma_range": (0.0001, 0.006), - # 평가셋에도 augmentation을 적용할지 여부. # False = clean simulation으로 평가 (실측 대비 낙관적) # True = 측정 노이즈가 실린 조건에서 평가 @@ -60,6 +57,7 @@ }, } + def save_config(config: dict, file: Path | str): def _to_serializable(obj): if isinstance(obj, dict): @@ -84,4 +82,5 @@ def _to_serializable(obj): if __name__ == "__main__": from pprint import pprint + pprint(CONFIG) diff --git a/runs/exp07_fourier_hires/dataset.py b/runs/exp07_fourier_hires/dataset.py index f5eb480..d27f8aa 100644 --- a/runs/exp07_fourier_hires/dataset.py +++ b/runs/exp07_fourier_hires/dataset.py @@ -9,7 +9,7 @@ class XRRPreprocessor: - def __init__(self, qs: np.ndarray, stats_file=None, device='cpu'): + def __init__(self, qs: np.ndarray, stats_file=None, device="cpu"): self.target_q = qs self.device = device self.param_mean = None @@ -33,20 +33,16 @@ def process_input(self, q_raw: np.ndarray, R_raw: np.ndarray) -> torch.Tensor: q_raw, R_log = q_raw[::-1], R_log[::-1] try: - f_cubic = interp1d(q_raw, R_log, kind='cubic', - fill_value="extrapolate", bounds_error=False) + f_cubic = interp1d(q_raw, R_log, kind="cubic", fill_value="extrapolate", bounds_error=False) R_interp = f_cubic(self.target_q) - R_interp = np.minimum(R_interp, 0.0) # Physical constraint + R_interp = np.minimum(R_interp, 0.0) # Physical constraint except ValueError: R_interp = np.interp(self.target_q, q_raw, R_log, left=-15.0, right=-15.0) q_valid_mask = (self.target_q >= np.min(q_raw)) & (self.target_q <= np.max(q_raw)) R_interp[~q_valid_mask] = -15.0 - return torch.stack([ - torch.from_numpy(R_interp.astype(np.float32)), - torch.from_numpy(q_valid_mask.astype(np.float32)) - ], dim=0) + 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 타입 체크 @@ -55,14 +51,23 @@ def normalize_parameters(self, params_real): params_norm = (params_real - mean) / std return torch.from_numpy(params_norm.astype(np.float32)) + class XRR1LayerDataset(Dataset): def __init__( - self, qs: np.ndarray, h5_file: str | Path, stats_file: str | Path, - mode: str = "train", val_ratio: float = 0.2, test_ratio: float = 0.1, - augment: bool = False, expand_factor: int = 1, aug_prob: float = 0.9, - intensity_scale: float = 0.1, q_shift_sigma: float = 0.002, + self, + qs: np.ndarray, + h5_file: str | Path, + stats_file: str | Path, + mode: str = "train", + val_ratio: float = 0.2, + test_ratio: float = 0.1, + augment: bool = False, + expand_factor: int = 1, + aug_prob: float = 0.9, + intensity_scale: float = 0.1, + q_shift_sigma: float = 0.002, res_sigma_range: tuple = (0.0001, 0.006), - augment_eval: bool = False + augment_eval: bool = False, ): self.hf = None self.h5_path = Path(h5_file) @@ -70,8 +75,8 @@ def __init__( self.mode = mode self.target_q = qs # augment_eval=True면 val/test에도 측정 노이즈를 적용해 평가 (expand는 train만) - self.augment = augment and (mode == 'train' or augment_eval) - self.expand_factor = expand_factor if (mode == 'train') else 1 + self.augment = augment and (mode == "train" or augment_eval) + self.expand_factor = expand_factor if (mode == "train") else 1 self.aug_prob = aug_prob self._load_metadata_only() @@ -86,13 +91,13 @@ def __init__( q_shift_sigma=q_shift_sigma, res_sigma_range=res_sigma_range, delta_q=float(src_q[1] - src_q[0]), - prob=aug_prob + prob=aug_prob, ) self.processor = XRRPreprocessor(self.target_q) # [Fix] 학습 모드일 때 통계량이 없으면 자동 계산 - if mode == 'train' and not self.stats_path.exists(): + if mode == "train" and not self.stats_path.exists(): self._calculate_and_save_stats() if self.stats_path.exists(): @@ -113,21 +118,14 @@ def _load_metadata_only(self): 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) + 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 # 0으로 나누기 방지 - torch.save({ - "param_mean": torch.from_numpy(mean.astype(np.float32)), - "param_std": torch.from_numpy(std.astype(np.float32)) - }, self.stats_path) + 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}") def _setup_split(self, val_ratio, test_ratio): @@ -150,13 +148,11 @@ def __getitem__(self, idx): real_idx = self.indices[original_idx] if self.hf is None: - self.hf = h5py.File(self.h5_path, 'r', swmr=True) + self.hf = h5py.File(self.h5_path, "r", swmr=True) R_raw = self.hf["R"][real_idx] q_raw = self.source_q[real_idx] if self.source_q.ndim == 2 else self.source_q - params_raw = np.array([self.thickness[real_idx], - self.roughness[real_idx], - self.sld[real_idx]], dtype=np.float32) + params_raw = np.array([self.thickness[real_idx], self.roughness[real_idx], self.sld[real_idx]], dtype=np.float32) if self.augment: q_raw, R_raw = self.physics_augmenter(q_raw, R_raw) @@ -167,5 +163,5 @@ def __getitem__(self, idx): return input_tensor, params_tensor def __del__(self): - if hasattr(self, 'hf') and self.hf is not None: + if hasattr(self, "hf") and self.hf is not None: self.hf.close() diff --git a/runs/exp07_fourier_hires/engine/fitting.py b/runs/exp07_fourier_hires/engine/fitting.py index 65c308f..57291f2 100644 --- a/runs/exp07_fourier_hires/engine/fitting.py +++ b/runs/exp07_fourier_hires/engine/fitting.py @@ -12,6 +12,7 @@ @dataclass class XRRConfig: """범용 XRR 분석을 위한 물리학적 파라미터 설정""" + wavelength: float = 1.5406 beam_width: float = 0.1 sample_len_init: float = 10.0 @@ -22,6 +23,7 @@ class XRRConfig: steps_fine: int = 1200 pop_size: int = 40 + class GenXFitter: def __init__(self, q: np.ndarray, R: np.ndarray, nn_params, config: XRRConfig = None): self.q = q @@ -42,13 +44,13 @@ def _initialize_genx_model(self) -> Model: model.data = DataList([ds]) init_vals = { - 'f_d': float(self.nn_params.thickness), - 'f_sig': float(self.nn_params.roughness), - 'f_sld': float(self.nn_params.sld), - 's_len': self.config.sample_len_init, - 'beam_w': self.config.beam_width, - 'i0': 1.0, - 'ibkg': 1e-7 + "f_d": float(self.nn_params.thickness), + "f_sig": float(self.nn_params.roughness), + "f_sld": float(self.nn_params.sld), + "s_len": self.config.sample_len_init, + "beam_w": self.config.beam_width, + "i0": 1.0, + "ibkg": 1e-7, } builder = GenXScriptBuilder() model.set_script(builder.build(init_vals)) @@ -61,6 +63,7 @@ def _setup_parameters(self): d_ai = float(self.nn_params.thickness) sld_ai = float(self.nn_params.sld) sig_ai = float(self.nn_params.roughness) + def add_par(name, val, min_v, max_v, fit=True): p = pars.append(name, model) p.min, p.max = min_v, max_v @@ -70,9 +73,9 @@ def add_par(name, val, min_v, max_v, fit=True): 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)) + 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): 저각 위상 조절의 핵심 add_par("v.set_s_d", 15.0, 0.01, 55.0) @@ -124,7 +127,7 @@ def run(self, verbose=True): if verbose: self._print_status("FINAL", time.time() - start) - print("="*185 + "\n") + print("=" * 185 + "\n") return self._collect_results() @@ -136,7 +139,7 @@ def _set_active_params(self, active_names: list[str]): self.pars_map[name].fit = True def _print_header(self): - print("\n" + "="*185) + print("\n" + "=" * 185) print( f"{'Fitting Step':^15} | {'FOM (logR1)':^20} | {'Thick':^7} | {'Rough':^5} | {'SLD':^5} | " f"{'SiO2_d':^6} | {'SiO2_s':^6} | {'SiO2_sld':^8} | {'I0':^5} | {'L':^4} | {'Bkg':^5} | {'Time':^7}" @@ -148,16 +151,18 @@ def _print_status(self, step_name: str, elapsed: float): p = self.pars_map fom_val = self.model.fom log_fom = np.log10(fom_val) if fom_val > 0 else -np.inf - b_val = np.log10(max(1e-12, p['set_ibkg'].value)) + b_val = np.log10(max(1e-12, p["set_ibkg"].value)) fom_str = f"{fom_val:.4e} ({log_fom:5.2f})" - print(f" >> [{step_name:^12}] {fom_str:^20} | " - f"{p['set_f_d'].value:7.2f} | {p['set_f_sig'].value:5.2f} | {p['set_f_sld'].value:5.2f} | " - f"{p['set_s_d'].value:6.2f} | {p['set_s_sig'].value:6.2f} | {p['set_s_sld'].value:8.2f} | " - f"{p['set_i0'].value:5.2f} | {p['set_s_len'].value:4.1f} | {b_val:.1f} | {elapsed:6.2f}s") + print( + f" >> [{step_name:^12}] {fom_str:^20} | " + f"{p['set_f_d'].value:7.2f} | {p['set_f_sig'].value:5.2f} | {p['set_f_sld'].value:5.2f} | " + f"{p['set_s_d'].value:6.2f} | {p['set_s_sig'].value:6.2f} | {p['set_s_sld'].value:8.2f} | " + f"{p['set_i0'].value:5.2f} | {p['set_s_len'].value:4.1f} | {b_val:.1f} | {elapsed:6.2f}s" + ) def _collect_results(self) -> dict: self.model.evaluate_sim_func() results = {name: p.value for name, p in self.pars_map.items()} - results.update({'R_sim': self.model.data[0].y_sim, 'fom': self.model.fom}) + results.update({"R_sim": self.model.data[0].y_sim, "fom": self.model.fom}) return results diff --git a/runs/exp07_fourier_hires/engine/inference.py b/runs/exp07_fourier_hires/engine/inference.py index 1efa214..a48c911 100644 --- a/runs/exp07_fourier_hires/engine/inference.py +++ b/runs/exp07_fourier_hires/engine/inference.py @@ -12,7 +12,8 @@ class XRRPreprocessor: """XRR 데이터 전처리 및 역변환을 담당하는 유틸리티 클래스""" - def __init__(self, qs: np.ndarray, stats_file: Path | str | None = None, device: torch.device = torch.device('cpu')): + + 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 self.param_mean = None @@ -40,10 +41,7 @@ def process_input(self, q_raw: np.ndarray, R_raw: np.ndarray) -> torch.Tensor: R_interp = np.interp(self.target_q, q_raw, R_log, left=-15.0, right=-15.0) q_valid_mask = (self.target_q >= np.min(q_raw)) & (self.target_q <= np.max(q_raw)) - return torch.stack([ - torch.from_numpy(R_interp.astype(np.float32)), - torch.from_numpy(q_valid_mask.astype(np.float32)) - ], dim=0) + 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)으로 변환""" @@ -84,18 +82,18 @@ def _init_processor(self): def _load_model(self): m_conf = self.config["model"] model_args = { - 'q_len': len(self.target_qs), - 'input_channels': 2, - 'n_channels': m_conf["n_channels"], - 'depth': m_conf["depth"], - 'mlp_hidden': m_conf["mlp_hidden"], - 'dropout': 0.0, - 'use_fourier': m_conf.get("use_fourier", True), - 'fourier_scale': m_conf.get("fourier_scale", 10.0) + "q_len": len(self.target_qs), + "input_channels": 2, + "n_channels": m_conf["n_channels"], + "depth": m_conf["depth"], + "mlp_hidden": m_conf["mlp_hidden"], + "dropout": 0.0, + "use_fourier": m_conf.get("use_fourier", True), + "fourier_scale": m_conf.get("fourier_scale", 10.0), } self.model = XRRPhysicsModel(**model_args).to(self.device) ckpt = torch.load(self.exp_dir / "best.pt", map_location=self.device, weights_only=True) - self.model.load_state_dict(ckpt['model_state_dict']) + self.model.load_state_dict(ckpt["model_state_dict"]) self.model.eval() def predict(self, q_raw: np.ndarray, R_raw: np.ndarray) -> ParamSet: @@ -117,15 +115,15 @@ def plot_ai_guess(self, q_measured: np.ndarray, R_measured: np.ndarray, ai_param R_ai = param2refl(q_measured, [ai_params]) plt.figure(figsize=(10, 6)) - plt.plot(q_measured, i0_normalize(R_measured), 'ko', markersize=3, alpha=0.3, label='Measured Data') - plt.plot(q_measured, R_ai, 'r--', lw=2, label=f'AI Guess (d={ai_params.thickness:.1f}Å)') + plt.plot(q_measured, i0_normalize(R_measured), "ko", markersize=3, alpha=0.3, label="Measured Data") + plt.plot(q_measured, R_ai, "r--", lw=2, label=f"AI Guess (d={ai_params.thickness:.1f}Å)") - plt.yscale('log') - plt.xlabel(r'$q$ ($\AA^{-1}$)') - plt.ylabel('Reflectivity') - plt.title('Initial AI Prediction (Guess)', fontsize=14, fontweight='bold') + plt.yscale("log") + plt.xlabel(r"$q$ ($\AA^{-1}$)") + plt.ylabel("Reflectivity") + plt.title("Initial AI Prediction (Guess)", fontsize=14, fontweight="bold") plt.legend() - plt.grid(True, which='both', alpha=0.2) + plt.grid(True, which="both", alpha=0.2) if save_path: plt.savefig(save_path, dpi=150) diff --git a/runs/exp07_fourier_hires/engine/script_builder.py b/runs/exp07_fourier_hires/engine/script_builder.py index ff2b15b..8e504b3 100644 --- a/runs/exp07_fourier_hires/engine/script_builder.py +++ b/runs/exp07_fourier_hires/engine/script_builder.py @@ -14,9 +14,9 @@ def to_f(sld_val): class Sim_Vars: def __init__(self): # Film (AI Guess) - self.f_d = {init_params['f_d']} - self.f_sig = {init_params['f_sig']} - self.f_sld = {init_params['f_sld']} + self.f_d = {init_params["f_d"]} + self.f_sig = {init_params["f_sig"]} + self.f_sld = {init_params["f_sld"]} # SiO2 (Fixed/Guess) self.s_d = 15.0 @@ -25,8 +25,8 @@ def __init__(self): # Instrument self.i0 = 1.0 - self.s_len = {init_params['s_len']} - self.beam_w = {init_params['beam_w']} + self.s_len = {init_params["s_len"]} + self.beam_w = {init_params["beam_w"]} # ================================================================= # [FIX] GenX requires BOTH Setters AND Getters for every parameter diff --git a/runs/exp07_fourier_hires/evaluate.py b/runs/exp07_fourier_hires/evaluate.py index 51f36e6..05151e0 100644 --- a/runs/exp07_fourier_hires/evaluate.py +++ b/runs/exp07_fourier_hires/evaluate.py @@ -22,6 +22,7 @@ # 유니코드 위첨자는 mathtext 폰트에 글리프가 없어 경고가 나므로 mathtext로 표기 PARAM_NAMES = ["Thickness (Å)", "Roughness (Å)", r"SLD (10$^{-6}$ Å$^{-2}$)"] + # ----------------------------------------------------------------------------- # 1. Advanced Metrics # ----------------------------------------------------------------------------- @@ -31,24 +32,26 @@ def calculate_r2(true, pred): ss_tot = np.sum((true - np.mean(true, axis=0)) ** 2, axis=0) return 1 - (ss_res / (ss_tot + 1e-8)) + def calculate_metrics(preds: np.ndarray, targets: np.ndarray) -> dict: errors = preds - targets abs_errors = np.abs(errors) mae = np.mean(abs_errors, axis=0) - rmse = np.sqrt(np.mean(errors ** 2, axis=0)) + rmse = np.sqrt(np.mean(errors**2, axis=0)) mape = np.mean(abs_errors / (np.abs(targets) + 1e-6), axis=0) * 100 r2 = calculate_r2(targets, preds) return { "errors": errors, - "abs_errors": abs_errors, # For finding worst cases + "abs_errors": abs_errors, # For finding worst cases "mae": mae, "rmse": rmse, "mape": mape, - "r2": r2 + "r2": r2, } + # ----------------------------------------------------------------------------- # 2. Reporting & Saving # ----------------------------------------------------------------------------- @@ -56,9 +59,10 @@ def save_results_csv(df: pd.DataFrame, save_path: Path): if not save_path: return save_path.parent.mkdir(parents=True, exist_ok=True) - df.to_csv(save_path, index=False, encoding='utf-8-sig') + df.to_csv(save_path, index=False, encoding="utf-8-sig") print(f"[Save] CSV Report: {save_path.name}") + def save_worst_cases(df: pd.DataFrame, abs_errors: np.ndarray, save_path: Path, top_k=50): """가장 에러가 큰 샘플들의 정보를 저장""" if not save_path: @@ -72,22 +76,20 @@ def save_worst_cases(df: pd.DataFrame, abs_errors: np.ndarray, save_path: Path, worst_df = df.iloc[worst_indices].copy() worst_df.insert(0, "Sample_ID", worst_indices) - worst_df.to_csv(save_path, index=False, encoding='utf-8-sig') + worst_df.to_csv(save_path, index=False, encoding="utf-8-sig") print(f"[Save] Worst Cases Log: {save_path.name}") return worst_indices + def print_metrics_table(metrics: dict, param_names: list): print("\n" + "=" * 80) print(f"{'Parameter':<20} | {'MAE':<10} | {'RMSE':<10} | {'MAPE (%)':<10} | {'R²':<10}") print("-" * 80) for i, name in enumerate(param_names): - print( - f"{name:<20} | {metrics['mae'][i]:<10.4f} | " - f"{metrics['rmse'][i]:<10.4f} | {metrics['mape'][i]:<10.2f} | " - f"{metrics['r2'][i]:<10.4f}" - ) + print(f"{name:<20} | {metrics['mae'][i]:<10.4f} | {metrics['rmse'][i]:<10.4f} | {metrics['mape'][i]:<10.2f} | {metrics['r2'][i]:<10.4f}") print("=" * 80) + # ----------------------------------------------------------------------------- # 3. Visualization (The Ultimate Plotter) # ----------------------------------------------------------------------------- @@ -97,12 +99,10 @@ def _outside_note(ax, frac: float, loc: str = "lower right"): return x, ha = (0.99, "right") if "right" in loc else (0.01, "left") y, va = (0.02, "bottom") if "lower" in loc else (0.98, "top") - ax.text(x, y, f"{frac:.1f}% outside axes", transform=ax.transAxes, - ha=ha, va=va, fontsize=9, color=INK) + ax.text(x, y, f"{frac:.1f}% outside axes", transform=ax.transAxes, ha=ha, va=va, fontsize=9, color=INK) -def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, - title_prefix: str = "", clip_axes: bool = False): +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 퍼센타일로 자르고 @@ -115,7 +115,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, fig, axes = plt.subplots(2, n_params, figsize=(5.2 * n_params, 9)) for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] y_true = df[f"{clean}_True"].values y_pred = df[f"{clean}_Pred"].values err = df[f"{clean}_Error"].values @@ -126,16 +126,15 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, min_v = min(y_true.min(), y_pred.min()) max_v = max(y_true.max(), y_pred.max()) - 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') + 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") # 지표는 항상 전체 데이터로 계산한다 - 축만 자른다 r2 = calculate_r2(y_true, y_pred) ax_s.set_title(f"{name} $R^2$ = {r2:.4f}") ax_s.set_xlabel("Ground truth") ax_s.set_ylabel("Prediction") - ax_s.legend(loc='upper left') + ax_s.legend(loc="upper left") if clip_axes: span = y_true.max() - y_true.min() @@ -143,7 +142,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, outside = np.mean((y_pred < lo) | (y_pred > hi)) * 100 ax_s.set_xlim(lo, hi) ax_s.set_ylim(lo, hi) - ax_s.set_aspect('equal', adjustable='box') + ax_s.set_aspect("equal", adjustable="box") _outside_note(ax_s, outside) # 2. Error histogram @@ -156,16 +155,14 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, pad = 0.05 * (hi_e - lo_e) hist_range = (lo_e - pad, hi_e + pad) - ax_h.hist(err, bins=60, density=True, range=hist_range, - color=color, edgecolor=INK, linewidth=0.5) - ax_h.axvline(0, color=INK, linestyle='--', linewidth=1.2) - ax_h.axvline(mu, color=INK, linestyle=':', linewidth=1.2, - label=f"Mean {mu:.2f}, SD {sigma:.2f}") + ax_h.hist(err, bins=60, density=True, range=hist_range, color=color, edgecolor=INK, linewidth=0.5) + ax_h.axvline(0, color=INK, linestyle="--", linewidth=1.2) + ax_h.axvline(mu, color=INK, linestyle=":", linewidth=1.2, label=f"Mean {mu:.2f}, SD {sigma:.2f}") ax_h.set_title("Error distribution") ax_h.set_xlabel("Error (pred − true)") ax_h.set_ylabel("Density") - ax_h.legend(loc='upper right') + ax_h.legend(loc="upper right") if hist_range is not None: ax_h.set_xlim(*hist_range) @@ -175,7 +172,7 @@ def save_correlation_plot(df: pd.DataFrame, param_names: list, save_path: Path, if title_prefix: fig.suptitle(title_prefix, fontsize=14, y=1.01) plt.tight_layout() - plt.savefig(save_path, dpi=150, bbox_inches='tight') + plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close() print(f"[Save] Correlation Plot: {save_path.name}") @@ -198,15 +195,12 @@ def save_history_plot(history: dict, save_path: Path, title_prefix: str = ""): style_axes(ax_lr) if train: - ax_loss.plot(epochs[:len(train)], train, label="Train", - color=ORIGIN_COLORS[0], lw=1.5) + ax_loss.plot(epochs[: len(train)], train, label="Train", color=ORIGIN_COLORS[0], lw=1.5) if val: - ax_loss.plot(epochs[:len(val)], val, label="Validation", - color=ORIGIN_COLORS[1], lw=1.5) + ax_loss.plot(epochs[: len(val)], val, label="Validation", color=ORIGIN_COLORS[1], lw=1.5) best_epoch = int(np.argmin(val)) + 1 best_val = float(np.min(val)) - ax_loss.plot([best_epoch], [best_val], marker="o", ms=8, mfc="none", - mec=INK, mew=1.2, ls="none", label=f"Best (E{best_epoch})") + ax_loss.plot([best_epoch], [best_val], marker="o", ms=8, mfc="none", mec=INK, mew=1.2, ls="none", label=f"Best (E{best_epoch})") ax_loss.set_title(f"Loss curve best val = {best_val:.6f}") else: ax_loss.set_title("Loss curve") @@ -217,7 +211,7 @@ def save_history_plot(history: dict, save_path: Path, title_prefix: str = ""): ax_loss.legend(loc="upper right") if lrs: - ax_lr.plot(epochs[:len(lrs)], lrs, color=ORIGIN_COLORS[3], lw=1.5) + ax_lr.plot(epochs[: len(lrs)], lrs, color=ORIGIN_COLORS[3], lw=1.5) ax_lr.set_yscale("log") ax_lr.set_xlabel("Epoch") ax_lr.set_ylabel("Learning rate") @@ -232,8 +226,7 @@ def save_history_plot(history: dict, save_path: Path, title_prefix: str = ""): print(f"[Save] History Plot: {save_path.name}") -def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, - title_prefix: str = ""): +def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, title_prefix: str = ""): """ 파라미터 조합(Thickness vs Roughness)에 따른 예측 난이도(MAE) 시각화 """ @@ -243,20 +236,14 @@ def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, # 분석용 임시 복사본 생성 및 절대 오차 계산 plot_df = df.copy() - plot_df['Abs_Thick_Err'] = plot_df['Thickness_Error'].abs() + 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)) + 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 - ) + 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) @@ -269,10 +256,10 @@ def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, pivot_table, annot=True, fmt=".2f", - cmap='Reds', - cbar_kws={'label': 'Mean absolute error (Å)'}, - linewidths=.5, - linecolor='white', + cmap="Reds", + cbar_kws={"label": "Mean absolute error (Å)"}, + linewidths=0.5, + linecolor="white", ax=ax, ) @@ -290,7 +277,7 @@ def plot_error_heatmap(df: pd.DataFrame, save_path: Path | None = None, # 4. 물리적 해석 가이드라인 추가 if save_path: - plt.savefig(save_path, dpi=150, bbox_inches='tight') + plt.savefig(save_path, dpi=150, bbox_inches="tight") print(f"[Save] Error Heatmap: {save_path.name}") plt.close() @@ -302,12 +289,12 @@ def plot_single_curve(ax_main, ax_res, qs, R_true, R_pred, title): style_axes(ax_res) # 측정값은 검은 실선(참조), 재구성은 계열 색 파선 - 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') + 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") ax_main.set_xticklabels([]) - ax_main.set_ylabel('Reflectivity') - ax_main.legend(loc='upper right', fontsize=9) + ax_main.set_ylabel("Reflectivity") + ax_main.legend(loc="upper right", fontsize=9) ax_main.set_title(title, fontsize=10) # Residuals (Log Difference) @@ -316,24 +303,21 @@ def plot_single_curve(ax_main, ax_res, qs, R_true, R_pred, title): diff = log_pred - log_true ax_res.plot(qs, diff, color=ORIGIN_COLORS[1], lw=1.0) - ax_res.axhline(0, color=INK, linestyle='--', lw=1.0) + ax_res.axhline(0, color=INK, linestyle="--", lw=1.0) ax_res.set_ylim(-2, 2) ax_res.set_ylabel(r"$\Delta$ log$R$", fontsize=9) ax_res.set_xlabel(r"$q$ ($\AA^{-1}$)", fontsize=10) + def save_advanced_reconstruction( - preds: np.ndarray, targets: np.ndarray, qs: np.ndarray, - save_path: Path, worst_indices: np.ndarray | None = None, n_random: int = 3 + preds: np.ndarray, targets: np.ndarray, qs: np.ndarray, save_path: Path, worst_indices: np.ndarray | None = None, n_random: int = 3 ): if not save_path: return # Selection: Top 3 Worst Cases + 3 Random Cases if worst_indices is not None and len(worst_indices) >= 3: - target_indices = np.concatenate([ - worst_indices[:3], - np.random.choice(len(preds), n_random, replace=False) - ]) + target_indices = np.concatenate([worst_indices[:3], np.random.choice(len(preds), n_random, replace=False)]) labels = ["Worst #1", "Worst #2", "Worst #3", "Random A", "Random B", "Random C"] else: target_indices = np.random.choice(len(preds), 6, replace=False) @@ -347,9 +331,7 @@ def save_advanced_reconstruction( for i, idx in enumerate(target_indices): # Create inner grid for Main + Residual - inner_grid = gridspec.GridSpecFromSubplotSpec( - 2, 1, subplot_spec=outer_grid[i], height_ratios=[3, 1], hspace=0.05 - ) + inner_grid = gridspec.GridSpecFromSubplotSpec(2, 1, subplot_spec=outer_grid[i], height_ratios=[3, 1], hspace=0.05) ax_main = plt.Subplot(fig, inner_grid[0]) ax_res = plt.Subplot(fig, inner_grid[1]) fig.add_subplot(ax_main) @@ -360,9 +342,9 @@ def save_advanced_reconstruction( p_p = preds[idx] # Physical Constraints for simulation - p_p[0] = max(p_p[0], 1.0) # Thickness > 1 - p_p[1] = max(p_p[1], 0.0) # Roughness > 0 - p_p[2] = max(p_p[2], 0.0) # SLD > 0 + p_p[0] = max(p_p[0], 1.0) # Thickness > 1 + p_p[1] = max(p_p[1], 0.0) # Roughness > 0 + p_p[2] = max(p_p[2], 0.0) # SLD > 0 # Simulation try: @@ -380,10 +362,11 @@ def save_advanced_reconstruction( plot_single_curve(ax_main, ax_res, qs, R_true, R_pred, f"{header}\n{info}") - plt.savefig(save_path, dpi=150, bbox_inches='tight') + plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close() print(f"[Save] Reconstruction Plot: {save_path.name}") + # ----------------------------------------------------------------------------- # 4. Pipeline # ----------------------------------------------------------------------------- @@ -391,7 +374,7 @@ def run_inference(model, loader, device): model.eval() preds, targets = [], [] print(f"Running inference on {device}...") - with torch.inference_mode(): # Faster than no_grad + with torch.inference_mode(): # Faster than no_grad for X, y in tqdm(loader, desc="Inference", leave=False): X = X.to(device) pred = model(X) @@ -399,6 +382,7 @@ def run_inference(model, loader, device): targets.append(y) return torch.cat(preds), torch.cat(targets) + def evaluate_pipeline( test_loader: DataLoader, checkpoint_path: Path, @@ -406,7 +390,7 @@ def evaluate_pipeline( qs: np.ndarray, report_img_path: Path | None = None, report_csv_path: Path | None = None, - report_history_path: Path | None = None + report_history_path: Path | None = None, ): if not checkpoint_path.exists() or not stats_path.exists(): print("❌ Error: Missing checkpoint or stats file.") @@ -446,7 +430,7 @@ def evaluate_pipeline( df = pd.DataFrame() for i, name in enumerate(param_names): - clean = name.split(' (')[0] + clean = name.split(" (")[0] df[f"{clean}_True"] = targets_np[:, i] df[f"{clean}_Pred"] = preds_np[:, i] df[f"{clean}_Error"] = metrics["errors"][:, i] @@ -467,10 +451,7 @@ def evaluate_pipeline( # Advanced Reconstruction recon_path = report_img_path.parent / "reconstruction_analysis.png" - save_advanced_reconstruction( - preds_np, targets_np, qs, recon_path, - worst_indices=worst_indices - ) + save_advanced_reconstruction(preds_np, targets_np, qs, recon_path, worst_indices=worst_indices) plot_error_heatmap(df, save_path=report_img_path.parent / "error_heatmap.png") @@ -518,7 +499,7 @@ def main(): mode="test", val_ratio=CONFIG["training"]["val_ratio"], test_ratio=CONFIG["training"]["test_ratio"], - augment=False # 평가 시에는 원본 데이터 사용 + augment=False, # 평가 시에는 원본 데이터 사용 ) test_loader = DataLoader( @@ -526,7 +507,7 @@ def main(): batch_size=CONFIG["training"]["batch_size"], shuffle=False, num_workers=CONFIG["training"]["num_workers"], - pin_memory=torch.cuda.is_available() + pin_memory=torch.cuda.is_available(), ) # 4. Pipeline 실행 @@ -538,11 +519,12 @@ def main(): qs=qs, report_img_path=exp_dir / "evaluation_correlation.png", report_csv_path=exp_dir / "evaluation_results.csv", - report_history_path=exp_dir / "training_history.png" + report_history_path=exp_dir / "training_history.png", ) print(f"\n✅ Evaluation Complete. Reports saved in: {exp_dir}") + if __name__ == "__main__": # 실행 시 GPU 메모리 효율화를 위해 세팅 if torch.cuda.is_available(): diff --git a/runs/exp07_fourier_hires/main.py b/runs/exp07_fourier_hires/main.py index 10a2722..8055e29 100644 --- a/runs/exp07_fourier_hires/main.py +++ b/runs/exp07_fourier_hires/main.py @@ -19,6 +19,7 @@ def set_seed(seed: int = 42): np.random.seed(seed) print(f"✅ Seed set to {seed}") + def ensure_data_exists(qs, config, h5_path): if not h5_path.exists(): print(f"📦 Data missing at {h5_path}. Generating clean source...") @@ -26,6 +27,7 @@ def ensure_data_exists(qs, config, h5_path): simulate.generate_1layer_data(qs, config, h5_path) + def get_dataloaders(qs, config, h5_file, stats_file): t_cfg = config["training"] @@ -47,18 +49,18 @@ def get_dataloaders(qs, config, h5_file, stats_file): 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(DataLoader( - ds, - batch_size=t_cfg["batch_size"], - shuffle=(mode=="train"), - num_workers=t_cfg["num_workers"], - pin_memory=torch.cuda.is_available(), - drop_last=(mode=="train") # Batch norm 안정성 - )) + ds = XRR1LayerDataset(**common_args, mode=mode, val_ratio=t_cfg["val_ratio"], test_ratio=t_cfg["test_ratio"]) + + loaders.append( + DataLoader( + ds, + batch_size=t_cfg["batch_size"], + shuffle=(mode == "train"), + num_workers=t_cfg["num_workers"], + pin_memory=torch.cuda.is_available(), + drop_last=(mode == "train"), # Batch norm 안정성 + ) + ) return loaders @@ -67,6 +69,7 @@ def register_nan_hooks(model): 모델의 모든 레이어에 NaN/Inf 감지 훅을 설치합니다. 문제가 발생하면 즉시 레이어 이름과 값을 출력하고 프로그램을 멈춥니다. """ + def forward_hook(module, input, output): if isinstance(output, torch.Tensor): if torch.isnan(output).any(): @@ -95,6 +98,7 @@ def backward_hook(module, grad_input, grad_output): module.register_forward_hook(forward_hook) module.register_full_backward_hook(backward_hook) + def register_debug_hooks(model): print("🕵️‍♀️ Installing Debug Hooks (Input/Weight Inspector)...") @@ -110,7 +114,7 @@ def forward_hook(module, input, output): raise RuntimeError(f"Bad Input at {module}") # 2. 가중치(Weights) 검사 (Conv/Linear 등) - if hasattr(module, 'weight') and module.weight is not None: + 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()}") @@ -121,7 +125,7 @@ def forward_hook(module, input, output): if torch.isnan(output).any() or torch.isinf(output).any(): print(f"\n💥 [CRITICAL] Output exploded AFTER {module}") print(f" - Input stats: min={x.min().item():.2e}, max={x.max().item():.2e}") - if hasattr(module, 'weight'): + if hasattr(module, "weight"): print(f" - Weight stats: min={module.weight.min().item():.2e}, max={module.weight.max().item():.2e}") raise RuntimeError(f"Explosion at {module}") @@ -130,6 +134,7 @@ def forward_hook(module, input, output): if len(list(module.children())) == 0: module.register_forward_hook(forward_hook) + def main(): print("🚀 EXP07 Launching: Physics-Informed Fourier Network") set_seed(42) @@ -156,14 +161,14 @@ def main(): m_cfg = CONFIG["model"] model = XRRPhysicsModel( q_len=sim_cfg["q_points"], - input_channels=2, # [LogR, Mask] - output_dim=3, # [Thick, Rough, SLD] + input_channels=2, # [LogR, Mask] + output_dim=3, # [Thick, Rough, SLD] n_channels=m_cfg["n_channels"], depth=m_cfg["depth"], mlp_hidden=m_cfg["mlp_hidden"], dropout=m_cfg["dropout"], use_fourier=m_cfg["use_fourier"], - fourier_scale=m_cfg["fourier_scale"] + fourier_scale=m_cfg["fourier_scale"], ) # Debug mode (config["debug"]["hooks"] = True 일 때만; 학습 속도 손실 큼) @@ -172,30 +177,32 @@ def main(): # 5. Trainer 실행 trainer = Trainer( - model, train_loader, val_loader, exp_dir, + model, + train_loader, + val_loader, + exp_dir, lr=CONFIG["training"]["lr"], weight_decay=CONFIG["training"]["weight_decay"], - patience=CONFIG["training"]["patience"] + patience=CONFIG["training"]["patience"], ) print("🔥 Training Start...") resume_path = exp_dir / "last.pt" - trainer.train( - CONFIG["training"]["epochs"], - resume_from=resume_path if resume_path.exists() else None - ) + 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( - test_loader, checkpoint_file, stats_file, qs, + test_loader, + checkpoint_file, + stats_file, + qs, report_img_path=exp_dir / "evaluation_report.png", report_csv_path=exp_dir / "evaluation_results.csv", - report_history_path=exp_dir / "training_history.png" + report_history_path=exp_dir / "training_history.png", ) if __name__ == "__main__": main() - diff --git a/runs/exp07_fourier_hires/make_report_figures.py b/runs/exp07_fourier_hires/make_report_figures.py index e3cd2f0..77a7adb 100644 --- a/runs/exp07_fourier_hires/make_report_figures.py +++ b/runs/exp07_fourier_hires/make_report_figures.py @@ -63,13 +63,15 @@ def metrics_from_df(df: pd.DataFrame) -> pd.DataFrame: true = df[f"{clean}_True"].to_numpy() pred = df[f"{clean}_Pred"].to_numpy() err = df[f"{clean}_Error"].to_numpy() - rows.append({ - "parameter": clean, - "MAE": np.mean(np.abs(err)), - "RMSE": np.sqrt(np.mean(err ** 2)), - "MAPE_%": np.mean(np.abs(err) / (np.abs(true) + 1e-6)) * 100, - "R2": calculate_r2(true, pred), - }) + rows.append( + { + "parameter": clean, + "MAE": np.mean(np.abs(err)), + "RMSE": np.sqrt(np.mean(err**2)), + "MAPE_%": np.mean(np.abs(err) / (np.abs(true) + 1e-6)) * 100, + "R2": calculate_r2(true, pred), + } + ) return pd.DataFrame(rows) @@ -101,12 +103,9 @@ def plot_error_vs_thickness(df: pd.DataFrame, save_path: Path, title: str): fig, ax = plt.subplots(figsize=(7.5, 4.6)) style_axes(ax) - ax.scatter(thick, abs_err, s=6, color=ORIGIN_COLORS[1], alpha=0.15, - edgecolor="none", zorder=1) - ax.fill_between(centers, q1, q3, color=ORIGIN_COLORS[0], alpha=0.20, - linewidth=0, zorder=2, label="Interquartile range") - ax.plot(centers, med, color=ORIGIN_COLORS[0], marker="o", ms=5, - mec=INK, mew=0.6, zorder=3, label="Median") + ax.scatter(thick, abs_err, s=6, color=ORIGIN_COLORS[1], alpha=0.15, edgecolor="none", zorder=1) + ax.fill_between(centers, q1, q3, color=ORIGIN_COLORS[0], alpha=0.20, linewidth=0, zorder=2, label="Interquartile range") + 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을 짓눌러 버린다. @@ -114,8 +113,7 @@ def plot_error_vs_thickness(df: pd.DataFrame, save_path: Path, title: str): 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) - ax.text(0.99, 0.02, f"{below:.0f}% of points below axis", - transform=ax.transAxes, ha="right", va="bottom", fontsize=9, color=INK) + ax.text(0.99, 0.02, f"{below:.0f}% of points below axis", transform=ax.transAxes, ha="right", va="bottom", fontsize=9, color=INK) ax.set_xlabel("True thickness (Å)") ax.set_ylabel("Absolute thickness error (Å)") @@ -128,8 +126,7 @@ def plot_error_vs_thickness(df: pd.DataFrame, save_path: Path, title: str): print(f" [Save] {save_path.name}") -def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, - domains: list[str], clip_axes: bool): +def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, domains: list[str], clip_axes: bool): var_dir = ablation_dir / variant if not var_dir.exists(): print(f"[Skip] {label}: {var_dir} not found") @@ -148,13 +145,11 @@ def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, df = pd.read_csv(csv_path) head = f"{shown} — {DOMAIN_TITLE[domain]}" - save_correlation_plot(df, PARAM_NAMES, - out_dir / f"{label}_parity_{domain}.png", - title_prefix=head, clip_axes=clip_axes) - plot_error_heatmap(df, save_path=out_dir / f"{label}_heatmap_{domain}.png", - title_prefix=head) + save_correlation_plot(df, PARAM_NAMES, out_dir / f"{label}_parity_{domain}.png", title_prefix=head, clip_axes=clip_axes) + plot_error_heatmap(df, save_path=out_dir / f"{label}_heatmap_{domain}.png", title_prefix=head) plot_error_vs_thickness( - df, out_dir / f"{label}_error_vs_thickness_{domain}.png", + df, + out_dir / f"{label}_error_vs_thickness_{domain}.png", f"{head}: thickness error vs film thickness", ) @@ -168,8 +163,7 @@ def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) history = ckpt.get("history", {}) if history: - save_history_plot(history, out_dir / f"{label}_learning_curve.png", - title_prefix=shown) + save_history_plot(history, out_dir / f"{label}_learning_curve.png", title_prefix=shown) # 곡선 재구성 그림은 GenX 시뮬레이션이 필요하므로 기존 산출물을 가져온다 recon = var_dir / "reconstruction_analysis.png" @@ -181,23 +175,22 @@ def build_for(label: str, variant: str, ablation_dir: Path, out_dir: Path, table = pd.concat(summary, ignore_index=True) table.to_csv(out_dir / f"{label}_metrics.csv", index=False, encoding="utf-8-sig") print(f" [Save] {label}_metrics.csv") - print(table.to_string(index=False, - float_format=lambda v: f"{v:.4f}")) + print(table.to_string(index=False, float_format=lambda v: f"{v:.4f}")) def main(): parser = argparse.ArgumentParser(description="Report figure builder") - parser.add_argument("--only", nargs="+", default=["current", "previous"], - help=f"출력할 라벨 (기본: current previous). 선택지: {list(LABELS)}") + parser.add_argument("--only", nargs="+", default=["current", "previous"], help=f"출력할 라벨 (기본: current previous). 선택지: {list(LABELS)}") parser.add_argument("--all", action="store_true", help="정의된 라벨 전체 출력") - parser.add_argument("--clean", action="store_true", - help="출력 폴더를 비우고 새로 생성 (이름 규칙이 바뀐 뒤 잔여 파일 제거용)") - parser.add_argument("--domains", nargs="+", default=["augmented"], - choices=["clean", "augmented"], - help="평가 도메인 (기본: augmented). 절대 성능이 아니라 " - "측정 조건에서의 성능을 보여주는 쪽이 augmented") - parser.add_argument("--no-clip", action="store_true", - help="축 자르기 없이 전체 범위로 그리기") + parser.add_argument("--clean", action="store_true", help="출력 폴더를 비우고 새로 생성 (이름 규칙이 바뀐 뒤 잔여 파일 제거용)") + parser.add_argument( + "--domains", + nargs="+", + default=["augmented"], + choices=["clean", "augmented"], + help="평가 도메인 (기본: augmented). 절대 성능이 아니라 측정 조건에서의 성능을 보여주는 쪽이 augmented", + ) + parser.add_argument("--no-clip", action="store_true", help="축 자르기 없이 전체 범위로 그리기") args = parser.parse_args() labels = list(LABELS) if args.all else args.only @@ -216,8 +209,7 @@ def main(): out_dir.mkdir(parents=True, exist_ok=True) for label in labels: - build_for(label, LABELS[label], ablation_dir, out_dir, - domains=args.domains, clip_axes=not args.no_clip) + build_for(label, LABELS[label], ablation_dir, out_dir, domains=args.domains, clip_axes=not args.no_clip) write_readme(out_dir, labels, args.domains) print(f"\n✅ Figures written to {out_dir}") diff --git a/runs/exp07_fourier_hires/metrics_report.py b/runs/exp07_fourier_hires/metrics_report.py index 862c7b0..71ee887 100644 --- a/runs/exp07_fourier_hires/metrics_report.py +++ b/runs/exp07_fourier_hires/metrics_report.py @@ -61,7 +61,7 @@ def core_metrics(true: np.ndarray, pred: np.ndarray) -> dict: return { "n": len(true), "MAE": float(abs_err.mean()), - "RMSE": float(np.sqrt((err ** 2).mean())), + "RMSE": float(np.sqrt((err**2).mean())), "MedAE": float(np.median(abs_err)), "P90_AE": float(np.percentile(abs_err, 90)), "P95_AE": float(np.percentile(abs_err, 95)), @@ -93,8 +93,7 @@ def build_extended(dfs: dict[str, pd.DataFrame]) -> pd.DataFrame: for variant, df in dfs.items(): for key, name, unit in PARAMS: m = core_metrics(df[f"{key}_True"].to_numpy(), df[f"{key}_Pred"].to_numpy()) - rows.append({"variant": variant, "label": REPORT_LABELS.get(variant, ""), - "parameter": name, "unit": unit, **m}) + rows.append({"variant": variant, "label": REPORT_LABELS.get(variant, ""), "parameter": name, "unit": unit, **m}) return pd.DataFrame(rows) @@ -126,9 +125,14 @@ def build_by_thickness(dfs: dict[str, pd.DataFrame]) -> pd.DataFrame: if mask.sum() == 0: continue sub = df[mask] - row = {"variant": variant, "label": REPORT_LABELS.get(variant, ""), - "band": f"{lo}-{hi} Å", "band_lo": lo, "band_hi": hi, - "n": int(mask.sum())} + row = { + "variant": variant, + "label": REPORT_LABELS.get(variant, ""), + "band": f"{lo}-{hi} Å", + "band_lo": lo, + "band_hi": hi, + "n": int(mask.sum()), + } for key, name, _ in PARAMS: m = core_metrics(sub[f"{key}_True"].to_numpy(), sub[f"{key}_Pred"].to_numpy()) row[f"{name}_MAE"] = m["MAE"] @@ -141,8 +145,7 @@ def build_by_thickness(dfs: dict[str, pd.DataFrame]) -> pd.DataFrame: # ----------------------------------------------------------------------------- # 비용 / 속도 # ----------------------------------------------------------------------------- -def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, - n_batches: int = 30) -> dict: +def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, n_batches: int = 30) -> dict: """test 로더로 실제 추론 처리량을 잰다. 데이터 로딩을 빼고 순수 forward만 잰다.""" import time @@ -162,8 +165,7 @@ def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, model_args = ckpt.get("config", {}).get("model_args", {}) model = XRRPhysicsModel(**model_args).to(device).eval() - (loader,) = make_loaders(qs, cfg, h5_file, var_dir / "stats.pt", - modes=["test"], num_workers=0) + (loader,) = make_loaders(qs, cfg, h5_file, var_dir / "stats.pt", modes=["test"], num_workers=0) # 배치를 미리 GPU로 올려두고 forward만 반복 측정한다 batches = [] @@ -175,7 +177,7 @@ def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, return {} with torch.inference_mode(): - for _ in range(3): # warm-up (cudnn autotune 포함) + for _ in range(3): # warm-up (cudnn autotune 포함) model(batches[0]) if device.type == "cuda": torch.cuda.synchronize() @@ -196,8 +198,7 @@ def benchmark_inference(variant: str, ablation_dir: Path, h5_file: Path, } -def build_cost(variants: list[str], ablation_dir: Path, h5_file: Path, - benchmark: bool) -> pd.DataFrame: +def build_cost(variants: list[str], ablation_dir: Path, h5_file: Path, benchmark: bool) -> pd.DataFrame: summary_path = ablation_dir / "ablation_summary.csv" summary = pd.read_csv(summary_path).set_index("variant") if summary_path.exists() else None @@ -216,9 +217,18 @@ def build_cost(variants: list[str], ablation_dir: Path, h5_file: Path, if summary is not None and v in summary.index: s = summary.loc[v] - for col in ["params", "train_samples", "epochs_budget", "epochs_ran", - "best_epoch", "early_stopped", "steps_per_epoch", - "total_steps", "best_val_loss", "train_seconds"]: + for col in [ + "params", + "train_samples", + "epochs_budget", + "epochs_ran", + "best_epoch", + "early_stopped", + "steps_per_epoch", + "total_steps", + "best_val_loss", + "train_seconds", + ]: if col in summary.columns: row[col] = s[col] @@ -253,8 +263,7 @@ def fmt(df: pd.DataFrame, floatfmt: str = "{:.4g}") -> str: widths = [max(len(c), *(len(v) for v in out[c])) if len(out) else len(c) for c in cols] header = "| " + " | ".join(c.ljust(w) for c, w in zip(cols, widths, strict=True)) + " |" sep = "|" + "|".join("-" * (w + 2) for w in widths) + "|" - body = ["| " + " | ".join(str(v).ljust(w) for v, w in zip(row, widths, strict=True)) + " |" - for row in out.itertuples(index=False, name=None)] + body = ["| " + " | ".join(str(v).ljust(w) for v, w in zip(row, widths, strict=True)) + " |" for row in out.itertuples(index=False, name=None)] return "\n".join([header, sep, *body]) @@ -283,10 +292,22 @@ def write_markdown(path: Path, tables: dict, budget_note: str): for tag in ["augmented", "clean"]: if f"extended_{tag}" not in tables: continue - lines += [f"## 평가 도메인: {tag}", "", - "### 파라미터별 오차 지표", "", fmt(tables[f"extended_{tag}"]), "", - "### 허용오차 내 샘플 비율 (%)", "", fmt(tables[f"tolerance_{tag}"], "{:.2f}"), "", - "### 두께 구간별 분해", "", fmt(tables[f"by_thickness_{tag}"]), ""] + lines += [ + f"## 평가 도메인: {tag}", + "", + "### 파라미터별 오차 지표", + "", + fmt(tables[f"extended_{tag}"]), + "", + "### 허용오차 내 샘플 비율 (%)", + "", + fmt(tables[f"tolerance_{tag}"], "{:.2f}"), + "", + "### 두께 구간별 분해", + "", + fmt(tables[f"by_thickness_{tag}"]), + "", + ] if "cost" in tables: lines += ["## 비용 / 속도", "", fmt(tables["cost"]), ""] @@ -297,12 +318,9 @@ def write_markdown(path: Path, tables: dict, budget_note: str): def main(): parser = argparse.ArgumentParser(description="exp07 확장 지표 산출") - parser.add_argument("--only", nargs="+", default=None, - help=f"대상 variant (기본: 전체). 선택지: {list(VARIANTS)}") - parser.add_argument("--domains", nargs="+", default=["augmented", "clean"], - choices=["augmented", "clean"]) - parser.add_argument("--benchmark", action="store_true", - help="추론 처리량을 실제 GPU에서 측정 (모델 로드 필요)") + parser.add_argument("--only", nargs="+", default=None, help=f"대상 variant (기본: 전체). 선택지: {list(VARIANTS)}") + parser.add_argument("--domains", nargs="+", default=["augmented", "clean"], choices=["augmented", "clean"]) + parser.add_argument("--benchmark", action="store_true", help="추론 처리량을 실제 GPU에서 측정 (모델 로드 필요)") args = parser.parse_args() variants = args.only or list(VARIANTS) @@ -344,8 +362,7 @@ def main(): print(" [Save] metrics_cost.csv") steps = tables["cost"]["total_steps"].max() if "total_steps" in tables["cost"] else None - budget_note = (f"> 학습 예산: variant당 약 {int(steps):,} optimizer steps (step-matched)" - if steps and not pd.isna(steps) else "") + budget_note = f"> 학습 예산: variant당 약 {int(steps):,} optimizer steps (step-matched)" if steps and not pd.isna(steps) else "" write_markdown(out_dir / "METRICS.md", tables, budget_note) print(f"\n✅ 지표 산출 완료 → {out_dir}") diff --git a/runs/exp07_fourier_hires/origin_style.py b/runs/exp07_fourier_hires/origin_style.py index 64eb01b..8b63071 100644 --- a/runs/exp07_fourier_hires/origin_style.py +++ b/runs/exp07_fourier_hires/origin_style.py @@ -35,68 +35,64 @@ 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, - "axes.titlesize": 12, - "axes.labelsize": 12, - "xtick.labelsize": 11, - "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, - "ytick.right": True, - "xtick.color": INK, - "ytick.color": INK, - "xtick.major.size": 5.0, - "ytick.major.size": 5.0, - "xtick.minor.size": 2.5, - "ytick.minor.size": 2.5, - "xtick.major.width": 1.2, - "ytick.major.width": 1.2, - "xtick.minor.width": 1.0, - "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", - "savefig.dpi": 300, - "savefig.bbox": "tight", - "figure.dpi": 100, - }) + mpl.rcParams.update( + { + # --- Font --- + "font.family": "sans-serif", + "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], + "font.size": 11, + "axes.titlesize": 12, + "axes.labelsize": 12, + "xtick.labelsize": 11, + "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, + "ytick.right": True, + "xtick.color": INK, + "ytick.color": INK, + "xtick.major.size": 5.0, + "ytick.major.size": 5.0, + "xtick.minor.size": 2.5, + "ytick.minor.size": 2.5, + "xtick.major.width": 1.2, + "ytick.major.width": 1.2, + "xtick.minor.width": 1.0, + "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", + "savefig.dpi": 300, + "savefig.bbox": "tight", + "figure.dpi": 100, + } + ) def style_axes(ax: plt.Axes, minor: bool = True) -> plt.Axes: @@ -106,10 +102,8 @@ def style_axes(ax: plt.Axes, minor: bool = True) -> plt.Axes: spine.set_linewidth(1.2) spine.set_color(INK) - ax.tick_params(which="major", direction="in", length=5.0, width=1.2, - top=True, right=True, color=INK) - ax.tick_params(which="minor", direction="in", length=2.5, width=1.0, - top=True, right=True, color=INK) + ax.tick_params(which="major", direction="in", length=5.0, width=1.2, top=True, right=True, color=INK) + ax.tick_params(which="minor", direction="in", length=2.5, width=1.0, top=True, right=True, color=INK) if minor: ax.minorticks_on() ax.grid(False) diff --git a/runs/exp07_fourier_hires/plot_ablation.py b/runs/exp07_fourier_hires/plot_ablation.py index e637d21..e11f686 100644 --- a/runs/exp07_fourier_hires/plot_ablation.py +++ b/runs/exp07_fourier_hires/plot_ablation.py @@ -79,8 +79,7 @@ def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): domains = [d for d in ("clean", "augmented") if d in dfs] y = np.arange(len(order))[::-1] # 첫 variant가 위로 오도록 - fig, axes = plt.subplots(1, len(PARAMS), figsize=(4.9 * len(PARAMS), 4.2), - sharey=True) + fig, axes = plt.subplots(1, len(PARAMS), figsize=(4.9 * len(PARAMS), 4.2), sharey=True) for ax, (key, title, xlabel) in zip(axes, PARAMS, strict=True): style_axes(ax) @@ -96,10 +95,18 @@ def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): for domain in domains: df = series[domain] vals = [df.loc[v, col] if v in df.index else np.nan for v in order] - ax.plot(vals, y, ls="none", - marker=DOMAIN_STYLE[domain]["marker"], ms=8, - mfc=DOMAIN_STYLE[domain]["color"], mec=INK, mew=0.8, - label=DOMAIN_STYLE[domain]["label"], zorder=3) + ax.plot( + vals, + y, + ls="none", + marker=DOMAIN_STYLE[domain]["marker"], + ms=8, + mfc=DOMAIN_STYLE[domain]["color"], + mec=INK, + mew=0.8, + label=DOMAIN_STYLE[domain]["label"], + zorder=3, + ) ax.set_xscale("log") ax.set_xlabel(xlabel) @@ -112,10 +119,8 @@ def plot_mae_by_variant(dfs: dict[str, pd.DataFrame], save_path: Path): # 패널 안에 두면 데이터 점을 가리므로 그림 상단에 가로로 배치 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) + 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) plt.tight_layout() plt.savefig(save_path) plt.close() @@ -144,13 +149,21 @@ def plot_domain_gap(dfs: dict[str, pd.DataFrame], save_path: Path): for i, (key, title, _) in enumerate(PARAMS): col = f"{key}_MAE" ratio = [aug.loc[v, col] / clean.loc[v, col] for v in order] - ax.plot(ratio, y + (i - (len(PARAMS) - 1) / 2) * offset, ls="none", - marker=ORIGIN_MARKERS[i], ms=8, - mfc=ORIGIN_COLORS[i], mec=INK, mew=0.8, label=title, zorder=3) + ax.plot( + ratio, + y + (i - (len(PARAMS) - 1) / 2) * offset, + ls="none", + marker=ORIGIN_MARKERS[i], + ms=8, + mfc=ORIGIN_COLORS[i], + mec=INK, + mew=0.8, + label=title, + zorder=3, + ) ax.axvline(1.0, color=INK, linestyle="--", lw=1.2) - ax.text(1.05, len(order) - 0.6, "no degradation", va="top", ha="left", - fontsize=9, color=INK) + ax.text(1.05, len(order) - 0.6, "no degradation", va="top", ha="left", fontsize=9, color=INK) ax.set_yticks(y) ax.set_yticklabels(order) @@ -183,18 +196,23 @@ def plot_r2_panel(dfs: dict[str, pd.DataFrame], save_path: Path): if len(domains) == 2: for yi, v in zip(y, order, strict=True): - _dumbbell(ax, yi, - 1.0 - series["clean"].loc[v, "Thickness_R2"], - 1.0 - series["augmented"].loc[v, "Thickness_R2"]) + _dumbbell(ax, yi, 1.0 - series["clean"].loc[v, "Thickness_R2"], 1.0 - series["augmented"].loc[v, "Thickness_R2"]) for domain in domains: df = series[domain] - vals = [1.0 - df.loc[v, "Thickness_R2"] if v in df.index else np.nan - for v in order] - ax.plot(vals, y, ls="none", - marker=DOMAIN_STYLE[domain]["marker"], ms=8, - mfc=DOMAIN_STYLE[domain]["color"], mec=INK, mew=0.8, - label=DOMAIN_STYLE[domain]["label"], zorder=3) + vals = [1.0 - df.loc[v, "Thickness_R2"] if v in df.index else np.nan for v in order] + ax.plot( + vals, + y, + ls="none", + marker=DOMAIN_STYLE[domain]["marker"], + ms=8, + mfc=DOMAIN_STYLE[domain]["color"], + mec=INK, + mew=0.8, + label=DOMAIN_STYLE[domain]["label"], + zorder=3, + ) ax.set_yticks(y) ax.set_yticklabels(order) diff --git a/runs/exp07_fourier_hires/simulate.py b/runs/exp07_fourier_hires/simulate.py index 527d948..531e214 100644 --- a/runs/exp07_fourier_hires/simulate.py +++ b/runs/exp07_fourier_hires/simulate.py @@ -22,12 +22,7 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): 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 - } + simulator_args: dict = {"qs": qs, "n_layers": 1, "n_samples": simulation["n_samples"], "has_noise": False} # config의 param_ranges 키 -> XRRSimulator 인자명 매핑 range_keys = { "thickness": "thickness_range", @@ -43,9 +38,7 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): if value is not None: simulator_args[arg_name] = tuple(value) - simulator = XRRSimulator( - **simulator_args - ) + simulator = XRRSimulator(**simulator_args) simulator.save_hdf5(h5_file, show_progress=True) @@ -62,8 +55,5 @@ def generate_1layer_data(qs: np.ndarray, config: dict, h5_file: Path | str): exp_dir.mkdir(parents=True, exist_ok=True) h5_file = exp_dir / "dataset.h5" - qs: np.ndarray = np.linspace( - CONFIG["simulation"]["q_min"], - CONFIG["simulation"]["q_max"], - CONFIG["simulation"]["q_points"]) + qs: np.ndarray = np.linspace(CONFIG["simulation"]["q_min"], CONFIG["simulation"]["q_max"], CONFIG["simulation"]["q_points"]) generate_1layer_data(qs, CONFIG, h5_file) diff --git a/runs/exp07_fourier_hires/train.py b/runs/exp07_fourier_hires/train.py index fd84ded..129aa0d 100644 --- a/runs/exp07_fourier_hires/train.py +++ b/runs/exp07_fourier_hires/train.py @@ -13,6 +13,7 @@ class LogMSELoss(nn.Module): XRR 데이터의 넓은 다이내믹 레인지를 고려한 손실 함수. 이미 입력이 Log 스케일이라면 가중치를 고각에 더 줄 수 있습니다. """ + def __init__(self): super().__init__() self.mse = nn.MSELoss() @@ -22,16 +23,19 @@ def forward(self, preds, targets): # 만약 특정 파라미터(예: 두께)에 가중치를 주고 싶다면 여기서 조정 return self.mse(preds, targets) + class Trainer: - def __init__(self, - model: nn.Module, train_loader: DataLoader, + def __init__( + self, + model: nn.Module, + train_loader: DataLoader, val_loader: DataLoader, save_dir: str | Path, - lr: float = 2e-4, # Fourier Feature 모델은 조금 낮은 LR이 안정적임 + lr: float = 2e-4, # Fourier Feature 모델은 조금 낮은 LR이 안정적임 weight_decay: float = 1e-4, - patience: int = 20 + patience: int = 20, ): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = model.to(self.device) self.train_loader = train_loader @@ -47,11 +51,9 @@ def __init__(self, self.criterion = nn.MSELoss() # Scheduler - self.scheduler = ReduceLROnPlateau( - self.optimizer, mode='min', patience=7, factor=0.5 - ) + self.scheduler = ReduceLROnPlateau(self.optimizer, mode="min", patience=7, factor=0.5) - self.best_val_loss = float('inf') + self.best_val_loss = float("inf") self.patience_counter = 0 self.early_stop_patience = patience @@ -88,7 +90,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: # [Physics-Informed Training] # autocast를 사용하여 연산 효율 증대 if self.scaler: - with torch.amp.autocast('cuda'): + with torch.amp.autocast("cuda"): preds = self.model(inputs) loss = self.criterion(preds, targets) @@ -105,7 +107,7 @@ def _train_epoch(self, epoch: int, total_epochs: int) -> float: self.optimizer.step() running_loss += loss.item() - pbar.set_postfix({'loss': f'{loss.item():.6f}'}) + pbar.set_postfix({"loss": f"{loss.item():.6f}"}) return running_loss / len(self.train_loader) @@ -133,12 +135,12 @@ def train(self, epochs: int, resume_from: str | Path | None = None): train_loss = self._train_epoch(epoch, epochs) val_loss = self._validate() - self.history['train'].append(train_loss) - self.history['val'].append(val_loss) - self.history['lr'].append(self.optimizer.param_groups[0]['lr']) + self.history["train"].append(train_loss) + self.history["val"].append(val_loss) + self.history["lr"].append(self.optimizer.param_groups[0]["lr"]) self.scheduler.step(val_loss) - current_lr = self.optimizer.param_groups[0]['lr'] + current_lr = self.optimizer.param_groups[0]["lr"] print(f"E{epoch:03d} | Train: {train_loss:.6f} | Val: {val_loss:.6f} | LR: {current_lr:.1e}") @@ -157,42 +159,44 @@ def train(self, epochs: int, resume_from: str | Path | None = None): def _save_checkpoint(self, filename: str, epoch: int, val_loss: float): path = self.checkpoint_dir / filename - model_config = getattr(self.model, 'config', {}) - - torch.save({ - 'epoch': epoch, - 'model_state_dict': self.model.state_dict(), - 'optimizer_state_dict': self.optimizer.state_dict(), - 'scheduler_state_dict': self.scheduler.state_dict(), - 'val_loss': val_loss, - 'best_val_loss': self.best_val_loss, - 'patience_counter': self.patience_counter, - 'history': self.history, - 'config': {'model_args': model_config} - }, path) + model_config = getattr(self.model, "config", {}) + + torch.save( + { + "epoch": epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "scheduler_state_dict": self.scheduler.state_dict(), + "val_loss": val_loss, + "best_val_loss": self.best_val_loss, + "patience_counter": self.patience_counter, + "history": self.history, + "config": {"model_args": model_config}, + }, + path, + ) def _load_checkpoint(self, filepath: str | Path) -> int: filepath = Path(filepath) if not filepath.exists(): return 1 checkpoint = torch.load(filepath, map_location=self.device, weights_only=False) - 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']) - self.history = checkpoint.get('history', self.history) + 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"]) + self.history = checkpoint.get("history", self.history) # last.pt의 'val_loss'는 마지막 epoch 값이지 최저값이 아니다. 그대로 쓰면 # 재개 후 기준이 느슨해져 더 나쁜 모델이 best.pt를 덮어쓴다. # 저장된 best_val_loss를 우선 쓰고, 없으면(구버전 ckpt) history에서 복원한다. - best = checkpoint.get('best_val_loss') + best = checkpoint.get("best_val_loss") if best is None: - val_hist = checkpoint.get('history', {}).get('val', []) - best = min(val_hist) if val_hist else checkpoint.get('val_loss', float('inf')) + val_hist = checkpoint.get("history", {}).get("val", []) + best = min(val_hist) if val_hist else checkpoint.get("val_loss", float("inf")) self.best_val_loss = best - self.patience_counter = checkpoint.get('patience_counter', 0) + self.patience_counter = checkpoint.get("patience_counter", 0) - print(f"🔄 Resuming from Epoch {checkpoint['epoch'] + 1} " - f"(best val = {self.best_val_loss:.6f}, patience {self.patience_counter})") - return checkpoint['epoch'] + 1 + print(f"🔄 Resuming from Epoch {checkpoint['epoch'] + 1} (best val = {self.best_val_loss:.6f}, patience {self.patience_counter})") + return checkpoint["epoch"] + 1 diff --git a/runs/exp07_fourier_hires/xrr_model.py b/runs/exp07_fourier_hires/xrr_model.py index 67841d0..3f453cb 100644 --- a/runs/exp07_fourier_hires/xrr_model.py +++ b/runs/exp07_fourier_hires/xrr_model.py @@ -8,10 +8,11 @@ class FourierFeatureMapping(nn.Module): Random Fourier Features: 좌표(q)를 고차원 주파수 공간으로 매핑하여 고주파(High-Frequency) 패턴 학습을 돕습니다. """ + def __init__(self, input_dim=1, mapping_size=64, scale=10.0): super().__init__() self.mapping_size = mapping_size - self.register_buffer('B', torch.randn(input_dim, mapping_size) * scale) + self.register_buffer("B", torch.randn(input_dim, mapping_size) * scale) def forward(self, x): if x.dim() == 2: @@ -20,35 +21,53 @@ def forward(self, x): x_proj = x_proj.transpose(1, 2) * 2 * np.pi return torch.cat([torch.sin(x_proj), torch.cos(x_proj)], dim=1) + class XRRPhysicsModel(nn.Module): - def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, - n_channels: int = 64, depth: int = 5, mlp_hidden: int = 256, - dropout: float = 0.1, use_fourier: bool = True, fourier_scale: float = 10.0): + def __init__( + self, + q_len: int, + input_channels: int = 2, + output_dim: int = 3, + n_channels: int = 64, + depth: int = 5, + mlp_hidden: int = 256, + dropout: float = 0.1, + use_fourier: bool = True, + fourier_scale: float = 10.0, + ): super().__init__() self.use_fourier = use_fourier inplace: bool = True # False while debugging # Checkpoint 호환성을 위한 Config 저장 self.config = { - 'q_len': q_len, 'input_channels': input_channels, 'output_dim': output_dim, - 'n_channels': n_channels, 'depth': depth, 'mlp_hidden': mlp_hidden, - 'dropout': dropout, 'use_fourier': use_fourier, 'fourier_scale': fourier_scale + "q_len": q_len, + "input_channels": input_channels, + "output_dim": output_dim, + "n_channels": n_channels, + "depth": depth, + "mlp_hidden": mlp_hidden, + "dropout": dropout, + "use_fourier": use_fourier, + "fourier_scale": fourier_scale, } self.fourier = FourierFeatureMapping(input_dim=1, mapping_size=32, scale=fourier_scale) enc_in_channels = input_channels + (32 * 2) if use_fourier else input_channels - self.register_buffer('q_grid', torch.linspace(0, 1, q_len).view(1, 1, -1)) + self.register_buffer("q_grid", torch.linspace(0, 1, q_len).view(1, 1, -1)) layers = [] curr_dim = enc_in_channels for i in range(depth): out_dim = n_channels * (2 ** min(i, 3)) - layers.append(nn.Sequential( - nn.Conv1d(curr_dim, out_dim, kernel_size=7, padding=3, bias=False), - nn.BatchNorm1d(out_dim), - nn.LeakyReLU(0.2, inplace=inplace), - nn.Dropout1d(dropout), - nn.MaxPool1d(kernel_size=2) - )) + layers.append( + nn.Sequential( + nn.Conv1d(curr_dim, out_dim, kernel_size=7, padding=3, bias=False), + nn.BatchNorm1d(out_dim), + nn.LeakyReLU(0.2, inplace=inplace), + nn.Dropout1d(dropout), + nn.MaxPool1d(kernel_size=2), + ) + ) curr_dim = out_dim self.encoder = nn.Sequential(*layers) @@ -59,14 +78,14 @@ def __init__(self, q_len: int, input_channels: int = 2, output_dim: int = 3, nn.Dropout(dropout), nn.Linear(mlp_hidden, mlp_hidden // 2), nn.LeakyReLU(0.2, inplace=inplace), - nn.Linear(mlp_hidden // 2, output_dim) + nn.Linear(mlp_hidden // 2, output_dim), ) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, (nn.Conv1d, nn.Linear)): - nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='leaky_relu') + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="leaky_relu") def forward(self, x): bs = x.shape[0] diff --git a/src/reflecto_exp/consts_genx.py b/src/reflecto_exp/consts_genx.py index 32264d0..b50c7f8 100644 --- a/src/reflecto_exp/consts_genx.py +++ b/src/reflecto_exp/consts_genx.py @@ -3,34 +3,11 @@ from genx.models.spec_nx import Coords, FootType, Instrument, Layer, Probe, ResType XRAY_TUBE: Final[Instrument] = Instrument( - probe=Probe.xray, - wavelength=1.54, - coords=Coords.q, - I0=1.0, - Ibkg=1e-10, - res=0.0001, - restype=ResType.fast_conv, - footype=FootType.none, - beamw=0.0 + probe=Probe.xray, wavelength=1.54, coords=Coords.q, I0=1.0, Ibkg=1e-10, res=0.0001, restype=ResType.fast_conv, footype=FootType.none, beamw=0.0 ) -AIR: Final[Layer] = Layer( - d=0.0, - f=complex(0, 0), - dens=0.0, - sigma=0.0 -) +AIR: Final[Layer] = Layer(d=0.0, f=complex(0, 0), dens=0.0, sigma=0.0) -SURFACE_SIO2: Final[Layer] = Layer( - d=15.0, - f=complex(14, 0.1), - dens=0.05, - sigma=2.0 -) +SURFACE_SIO2: Final[Layer] = Layer(d=15.0, f=complex(14, 0.1), dens=0.05, sigma=2.0) -SUBSTRATE_SI: Final[Layer] = Layer( - d=0.0, - f=complex(13.5, 0.05), - dens=0.05, - sigma=2.0 -) +SUBSTRATE_SI: Final[Layer] = Layer(d=0.0, f=complex(13.5, 0.05), dens=0.05, sigma=2.0) diff --git a/src/reflecto_exp/io_utils.py b/src/reflecto_exp/io_utils.py index 966c07c..3f3900d 100644 --- a/src/reflecto_exp/io_utils.py +++ b/src/reflecto_exp/io_utils.py @@ -54,6 +54,7 @@ def next_unique_file(path: Path | str) -> Path: return parent / f"{stem}({max_n + 1}){suffix}" + def load_xrr_dat(file: Path | str) -> tuple[ndarray, ndarray]: """ Load 2 column .dat file (2-theta, Intensity). @@ -68,17 +69,11 @@ def load_xrr_dat(file: Path | str) -> tuple[ndarray, ndarray]: # 1. read_csv의 파라미터를 활용해 한 번에 파싱 # comment='#': #으로 시작하는 줄이나, 데이터 뒤에 붙은 # 주석을 무시함 # sep=r"\s+": 탭, 공백 등 길이가 다른 공백도 분리자로 인식 - df = pd.read_csv( - file, - header=None, - sep=r"\s+", - comment='#', - names=["tth", "R"] - ) + df = pd.read_csv(file, header=None, sep=r"\s+", comment="#", names=["tth", "R"]) # 2. 숫자가 아닌 데이터(Garbage)가 섞여 있을 경우를 대비한 안전장치 # (이미 comment='#'로 대부분 걸러지지만, 혹시 모를 텍스트 찌꺼기 처리) - df = df.apply(pd.to_numeric, errors='coerce') + df = df.apply(pd.to_numeric, errors="coerce") # 3. NaN(변환 실패한 값)을 0.0으로 채우고 Numpy 변환 # 원본 데이터 순서를 보장하기 위해 fillna 사용 diff --git a/src/reflecto_exp/math_utils.py b/src/reflecto_exp/math_utils.py index 097a669..dc4eaad 100644 --- a/src/reflecto_exp/math_utils.py +++ b/src/reflecto_exp/math_utils.py @@ -15,12 +15,14 @@ def powerspace( x = np.linspace(0, 1, num, endpoint=endpoint, dtype=dtype, axis=axis) return start + (stop - start) * x**power + def fom_log(ref_exp, ref_calc): """ Calculates the Figure of Merit (FOM) based on the log-scale reflectivity difference. """ return np.mean(np.abs(np.log10(ref_exp) - np.log10(ref_calc))) + def i0_normalize(arr): """ Normalizes the input array by dividing all elements by the maximum value (I0 normalization). @@ -34,8 +36,7 @@ def i0_normalize(arr): # The RuntimeWarning is used here as it indicates an issue encountered # during runtime that doesn't halt execution but requires attention. warnings.warn( - "Input array contains only zero values (max value is 0). Normalization skipped. Returning original array.", - RuntimeWarning, stacklevel=2 + "Input array contains only zero values (max value is 0). Normalization skipped. Returning original array.", RuntimeWarning, stacklevel=2 ) return arr return arr / arr.max() @@ -43,6 +44,7 @@ def i0_normalize(arr): def main() -> None: import matplotlib.pyplot as plt + power_grid = powerspace(0.1, 0.8, 100, power=1.8) plt.plot(power_grid) plt.show() diff --git a/src/reflecto_exp/physics_utils.py b/src/reflecto_exp/physics_utils.py index 23292e8..00c6f09 100644 --- a/src/reflecto_exp/physics_utils.py +++ b/src/reflecto_exp/physics_utils.py @@ -5,12 +5,15 @@ r_e: Final[float] = 2.8179403227e-5 # AA + @overload def tth2q(tth: float, wavelen: float = 1.54) -> float: ... + @overload def tth2q(tth: NDArray[np.float64], wavelen: float = 1.54) -> NDArray[np.float64]: ... + def tth2q(tth, wavelen=1.54): """ Convert 2θ (in degrees) and wavelength (in Å) to scattering vector q (in 1/Å). diff --git a/src/reflecto_exp/simulate/noise.py b/src/reflecto_exp/simulate/noise.py index 42bb1c3..2d74199 100644 --- a/src/reflecto_exp/simulate/noise.py +++ b/src/reflecto_exp/simulate/noise.py @@ -7,14 +7,16 @@ def apply_poisson_noise(arr: np.ndarray, s: float) -> np.ndarray: noisy_counts = np.random.poisson(expected_counts) return noisy_counts / s + def get_background_noise(count: int, b_min: float, b_max: float) -> np.ndarray: """Generate background noise.""" b = pow(10, np.random.uniform(b_min, b_max)) return np.random.normal(b, 0.1 * b, count) + def add_noise(R): N = len(R) - R_poisson = apply_poisson_noise(R, s=10 ** 8) + R_poisson = apply_poisson_noise(R, s=10**8) uniform_noise = 1 + np.random.uniform(-0.1, 0.1, N) background_noise = get_background_noise(N, -8, -6) curve_scaling = np.random.uniform(0.99, 1.01) diff --git a/src/reflecto_exp/simulate/simul_genx.py b/src/reflecto_exp/simulate/simul_genx.py index 7ea12e3..48421ef 100644 --- a/src/reflecto_exp/simulate/simul_genx.py +++ b/src/reflecto_exp/simulate/simul_genx.py @@ -31,26 +31,14 @@ def from_genx_layer(cls, layer: Layer) -> "ParamSet": # f는 복소수이므로 실수부(real)만 취함 (전자밀도 관련) sld_val = (layer.f.real * layer.dens * r_e) * 1e6 - return cls( - thickness=float(layer.d), - roughness=float(layer.sigma), - sld=float(sld_val) - ) + return cls(thickness=float(layer.d), roughness=float(layer.sigma), sld=float(sld_val)) def to_layer(self) -> Layer: """Return genx Layer object""" - return Layer( - d=self.thickness, - f=get_f(self.sld), - dens=1.0, - sigma=self.roughness - ) + return Layer(d=self.thickness, f=get_f(self.sld), dens=1.0, sigma=self.roughness) def __format__(self, format_spec: str) -> str: - formatted = [ - f"{name}={getattr(self, name):{format_spec}}" - for name in self.__dataclass_fields__.keys() - ] + formatted = [f"{name}={getattr(self, name):{format_spec}}" for name in self.__dataclass_fields__.keys()] return f"{self.__class__.__name__}({', '.join(formatted)})" @@ -72,17 +60,11 @@ def build_sample(film_params: list[ParamSet], sio2_param: ParamSet, sub_param: P sub_param = ParamSet(0, 3, 20.1) layers = [sio2_param.to_layer()] for param in film_params: - layers.append( - param.to_layer() - ) + layers.append(param.to_layer()) stack = Stack(Layers=layers, Repetitions=1) - sample = Sample( - Stacks=[stack], - Ambient=AIR, - Substrate=sub_param.to_layer() - ) + sample = Sample(Stacks=[stack], Ambient=AIR, Substrate=sub_param.to_layer()) return sample @@ -110,20 +92,20 @@ def param2refl(qs: np.ndarray, film_params: list[ParamSet], sio2_param: ParamSet class XRRSimulator: def __init__( - self, - qs: np.ndarray, - n_samples: int, - n_layers: int, - # Ranges - thickness_range: tuple[float, float] = (10.0, 500.0), - roughness_range: tuple[float, float] = (0.0, 20.0), - sld_range: tuple[float, float] = (5.0, 150.0), - sio2_thick_range: tuple[float, float] = (10.0, 25.0), - sio2_rough_range: tuple[float, float] = (2.0, 5.0), - sio2_sld_range: tuple[float, float] = (5, 22), - sub_rough_range: tuple[float, float] = (0.0, 5.0), - has_noise: bool = False, - ): + self, + qs: np.ndarray, + n_samples: int, + n_layers: int, + # Ranges + thickness_range: tuple[float, float] = (10.0, 500.0), + roughness_range: tuple[float, float] = (0.0, 20.0), + sld_range: tuple[float, float] = (5.0, 150.0), + sio2_thick_range: tuple[float, float] = (10.0, 25.0), + sio2_rough_range: tuple[float, float] = (2.0, 5.0), + sio2_sld_range: tuple[float, float] = (5, 22), + sub_rough_range: tuple[float, float] = (0.0, 5.0), + has_noise: bool = False, + ): self.qs = qs self.n_samples = n_samples @@ -177,18 +159,18 @@ def generate_batch(self) -> Iterator[tuple[list[ParamSet], ParamSet, np.ndarray] def save_hdf5(self, file: str | Path, show_progress: bool = True) -> None: file = Path(file) - with h5py.File(file, 'w') as hf: + with h5py.File(file, "w") as hf: hf.create_dataset("q", data=self.qs) - d_thick = hf.create_dataset("thickness", (self.n_samples, self.n_layers), dtype='f4') - d_rough = hf.create_dataset("roughness", (self.n_samples, self.n_layers), dtype='f4') - d_sld = hf.create_dataset("sld", (self.n_samples, self.n_layers), dtype='f4') + d_thick = hf.create_dataset("thickness", (self.n_samples, self.n_layers), dtype="f4") + d_rough = hf.create_dataset("roughness", (self.n_samples, self.n_layers), dtype="f4") + d_sld = hf.create_dataset("sld", (self.n_samples, self.n_layers), dtype="f4") - d_s_thick = hf.create_dataset("sio2_thickness", (self.n_samples, 1), dtype='f4') - d_s_rough = hf.create_dataset("sio2_roughness", (self.n_samples, 1), dtype='f4') - d_s_sld = hf.create_dataset("sio2_sld", (self.n_samples, 1), dtype='f4') + d_s_thick = hf.create_dataset("sio2_thickness", (self.n_samples, 1), dtype="f4") + d_s_rough = hf.create_dataset("sio2_roughness", (self.n_samples, 1), dtype="f4") + d_s_sld = hf.create_dataset("sio2_sld", (self.n_samples, 1), dtype="f4") - d_refl = hf.create_dataset("R", (self.n_samples, len(self.qs)), dtype='f4') + d_refl = hf.create_dataset("R", (self.n_samples, len(self.qs)), dtype="f4") iterator = self.generate_batch() if show_progress: @@ -201,7 +183,7 @@ def save_hdf5(self, file: str | Path, show_progress: bool = True) -> None: d_s_thick[i] = sp.thickness d_s_rough[i] = sp.roughness - d_s_sld[i] = sp.sld + d_s_sld[i] = sp.sld d_refl[i] = r @@ -212,24 +194,16 @@ def main(): qs = np.linspace(0.0, 0.5, 200) - sim = XRRSimulator( - qs=qs, - n_samples=5, - n_layers=2, - thickness_range=(50, 150), - roughness_range=(0, 5), - sld_range=(8, 12), - has_noise=False - ) + sim = XRRSimulator(qs=qs, n_samples=5, n_layers=2, thickness_range=(50, 150), roughness_range=(0, 5), sld_range=(8, 12), has_noise=False) print("Testing Simulation...") for films, sio2, refl in sim.generate_batch(): print(f"Films: {films}") print(f"SiO2: {sio2}") - plt.figure(figsize=(6,4)) + plt.figure(figsize=(6, 4)) plt.plot(qs, refl) - plt.yscale('log') + plt.yscale("log") title: str = f"SiO2: {sio2:.2g}\n" title += "\n".join(f"{i}: {film:.2g}" for i, film in enumerate(films, 1)) plt.title(title) diff --git a/src/reflecto_exp/simulate/simul_refnx.py b/src/reflecto_exp/simulate/simul_refnx.py index ac107e0..2738f73 100644 --- a/src/reflecto_exp/simulate/simul_refnx.py +++ b/src/reflecto_exp/simulate/simul_refnx.py @@ -23,10 +23,7 @@ def to_numpy(self) -> np.ndarray: def __format__(self, format_spec: str) -> str: """클래스 전체에 포매팅 명령어 적용 (.3f, .2f 등)""" - formatted = [ - f"{name}={getattr(self, name):{format_spec}}" - for name in self.__dataclass_fields__.keys() - ] + formatted = [f"{name}={getattr(self, name):{format_spec}}" for name in self.__dataclass_fields__.keys()] return f"{self.__class__.__name__}({', '.join(formatted)})" @@ -57,6 +54,7 @@ def build_structure(params: list[ParamSet]) -> Structure: return air(0, 0) | stack | substrate(0, 3) + def calc_refl(structure: Structure, q: np.ndarray, wavelen: float = 1.54, is_footprint: bool = True) -> np.ndarray: """ Compute reflectivity including beam footprint correction. @@ -79,7 +77,6 @@ def calc_refl(structure: Structure, q: np.ndarray, wavelen: float = 1.54, is_foo model = ReflectModel(structure) R = model(q) if is_footprint: - footprint = Footprint(sample_length=10.0, beam_height=0.1) theta = np.degrees(np.arcsin(np.clip(q * wavelen / (4 * np.pi), -1, 1))) frac = footprint(theta) @@ -91,24 +88,24 @@ def calc_refl(structure: Structure, q: np.ndarray, wavelen: float = 1.54, is_foo class XRRSimulator: def __init__( - self, - qs: np.ndarray, - n_layers:int, - n_samples: int, - thickness_range: tuple[float, float] = (30.0, 100.0), - roughness_range: tuple[float, float] = (0.0, 10.0), - sld_range: tuple[float, float] = (0.5, 10.0), - max_total_thickness: float = 250, - has_noise: bool = True, - has_footprint: bool = False - ): + self, + qs: np.ndarray, + n_layers: int, + n_samples: int, + thickness_range: tuple[float, float] = (30.0, 100.0), + roughness_range: tuple[float, float] = (0.0, 10.0), + sld_range: tuple[float, float] = (0.5, 10.0), + max_total_thickness: float = 250, + has_noise: bool = True, + has_footprint: bool = False, + ): self.qs = qs self.n_layers = n_layers self.n_samples = n_samples - self.thick_range = thickness_range # Å + self.thick_range = thickness_range # Å self.rough_range = roughness_range # Å, 주로 0-10Å 세밀 - self.sld_range = sld_range # x1e-6 Å^-2 + self.sld_range = sld_range # x1e-6 Å^-2 self.max_total_thickness = max_total_thickness self.has_noise = has_noise @@ -157,27 +154,24 @@ def simulate_one(self, params, has_noise=True, has_footprint=True) -> np.ndarray def save_hdf5(self, file: str | Path, show_progress: bool = True) -> None: file = Path(file) - with h5py.File(file, 'w') as hf: + with h5py.File(file, "w") as hf: hf.create_dataset("q", data=self.qs) - d_thick = hf.create_dataset("thickness", (self.n_samples, self.n_layers), dtype='f4') - d_rough = hf.create_dataset("roughness", (self.n_samples, self.n_layers), dtype='f4') - d_sld = hf.create_dataset("sld", (self.n_samples, self.n_layers), dtype='f4') - d_refl = hf.create_dataset( - "R", (self.n_samples, len(self.qs)), dtype='f4' - ) + d_thick = hf.create_dataset("thickness", (self.n_samples, self.n_layers), dtype="f4") + d_rough = hf.create_dataset("roughness", (self.n_samples, self.n_layers), dtype="f4") + d_sld = hf.create_dataset("sld", (self.n_samples, self.n_layers), dtype="f4") + d_refl = hf.create_dataset("R", (self.n_samples, len(self.qs)), dtype="f4") iterator = self.make_params_refl() if show_progress: iterator = tqdm( iterator, - total= self.n_samples, + total=self.n_samples, desc="Saving HDF5", dynamic_ncols=True, ) for i, (thicknesses, roughnesses, slds, refl) in enumerate(iterator): - d_thick[i] = thicknesses d_rough[i] = roughnesses d_sld[i] = slds @@ -190,7 +184,7 @@ def main() -> None: file: Path = root / "xrr_data.h5" # Measurement Configurations wavelen: float = 1.54 # (nm) - tth_min: float = 1.0 # degree + tth_min: float = 1.0 # degree tth_max: float = 6.0 q_min: float = tth2q(tth_min, wavelen) # (1/Å) q_max: float = tth2q(tth_max, wavelen) diff --git a/src/reflecto_exp/simulate/torch_engine/__init__.py b/src/reflecto_exp/simulate/torch_engine/__init__.py index 3100524..81eac3c 100644 --- a/src/reflecto_exp/simulate/torch_engine/__init__.py +++ b/src/reflecto_exp/simulate/torch_engine/__init__.py @@ -56,4 +56,3 @@ def simulate_reflectivity( reflectivity_curves = reflectivity_curves * r_scale + bkg return reflectivity_curves - diff --git a/src/reflecto_exp/simulate/torch_engine/__main__.py b/src/reflecto_exp/simulate/torch_engine/__main__.py index 26b37ef..714c720 100644 --- a/src/reflecto_exp/simulate/torch_engine/__main__.py +++ b/src/reflecto_exp/simulate/torch_engine/__main__.py @@ -12,13 +12,7 @@ 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 = simulate_reflectivity(q=q, thickness=thickness, roughness=roughness, sld=sld, dq_q=torch.tensor([[0.02]]).to(device)) R_numpy = R.detach().cpu().numpy() q_numpy = q.detach().cpu().numpy() diff --git a/src/reflecto_exp/simulate/torch_engine/abeles.py b/src/reflecto_exp/simulate/torch_engine/abeles.py index 6575b23..6a47e96 100644 --- a/src/reflecto_exp/simulate/torch_engine/abeles.py +++ b/src/reflecto_exp/simulate/torch_engine/abeles.py @@ -21,16 +21,12 @@ def abeles( # add zero ambient sld sld = torch.cat([torch.zeros(batch_size, 1).to(sld), sld], -1) if sld.shape[-1] != num_layers + 2: - raise ValueError( - "Number of SLD values does not equal to num_layers + 2 (substrate + ambient)." - ) + raise ValueError("Number of SLD values does not equal to num_layers + 2 (substrate + ambient).") sld = sld[:, None] # add zero thickness for ambient layer: - thickness = torch.cat([torch.zeros(batch_size, 1).to(thickness), thickness], -1)[ - :, None - ] + thickness = torch.cat([torch.zeros(batch_size, 1).to(thickness), thickness], -1)[:, None] roughness = roughness[:, None] ** 2 diff --git a/src/reflecto_exp/simulate/torch_engine/instrument_uitls.py b/src/reflecto_exp/simulate/torch_engine/instrument_uitls.py index cfbf272..60e35f2 100644 --- a/src/reflecto_exp/simulate/torch_engine/instrument_uitls.py +++ b/src/reflecto_exp/simulate/torch_engine/instrument_uitls.py @@ -4,6 +4,7 @@ rad = np.pi / 180.0 sqrt2 = np.sqrt(2.0) + def GaussIntensity(alpha, s1, s2, sigma_x): sinalpha = np.sin(alpha * rad) if s1 == s2: diff --git a/src/reflecto_exp/simulate/torch_engine/kinematical.py b/src/reflecto_exp/simulate/torch_engine/kinematical.py index d7b59a4..21286df 100644 --- a/src/reflecto_exp/simulate/torch_engine/kinematical.py +++ b/src/reflecto_exp/simulate/torch_engine/kinematical.py @@ -32,20 +32,13 @@ def kinematical_approximation( sld = sld * 1e-6 + 1e-30j - drho = torch.cat([sld[..., 0][..., None], sld[..., 1:] - sld[..., :-1]], -1)[ - :, None - ] - thickness = torch.cumsum( - torch.cat([torch.zeros(batch_size, 1).to(thickness), thickness], -1), -1 - )[:, None] + drho = torch.cat([sld[..., 0][..., None], sld[..., 1:] - sld[..., :-1]], -1)[:, None] + thickness = torch.cumsum(torch.cat([torch.zeros(batch_size, 1).to(thickness), thickness], -1), -1)[:, None] roughness = roughness[:, None] - r = (drho * torch.exp(-((roughness * q) ** 2) / 2 + 1j * (q * thickness))).sum( - -1 - ).abs().float() ** 2 + r = (drho * torch.exp(-((roughness * q) ** 2) / 2 + 1j * (q * thickness))).sum(-1).abs().float() ** 2 if apply_fresnel: - substrate_sld = sld[:, -1:] rf = _get_resnel_reflectivity(q, substrate_sld[:, None]) diff --git a/src/reflecto_exp/simulate/torch_engine/memory_eff.py b/src/reflecto_exp/simulate/torch_engine/memory_eff.py index 493bbbb..cae329e 100644 --- a/src/reflecto_exp/simulate/torch_engine/memory_eff.py +++ b/src/reflecto_exp/simulate/torch_engine/memory_eff.py @@ -33,34 +33,23 @@ def abeles_memory_eff( thickness_prev_layer = 1.0 # ambient for interface_num in range(num_interfaces): - prev_layer_idx = interface_num - 1 next_layer_idx = interface_num if interface_num == 0: - k_z_previous_layer = _get_relative_k_z( - k_z0, torch.zeros(batch_size, 1).to(sld) - ) + k_z_previous_layer = _get_relative_k_z(k_z0, torch.zeros(batch_size, 1).to(sld)) else: thickness_prev_layer = thickness[:, prev_layer_idx].unsqueeze(1) - k_z_previous_layer = _get_relative_k_z( - k_z0, sld[:, prev_layer_idx].unsqueeze(1) - ) + k_z_previous_layer = _get_relative_k_z(k_z0, sld[:, prev_layer_idx].unsqueeze(1)) - k_z_next_layer = _get_relative_k_z( - k_z0, sld[:, next_layer_idx].unsqueeze(1) - ) # (batch_num, q_num) + k_z_next_layer = _get_relative_k_z(k_z0, sld[:, next_layer_idx].unsqueeze(1)) # (batch_num, q_num) - reflection_matrix = _make_reflection_matrix( - k_z_previous_layer, k_z_next_layer, roughness[:, interface_num].unsqueeze(1) - ) + reflection_matrix = _make_reflection_matrix(k_z_previous_layer, k_z_next_layer, roughness[:, interface_num].unsqueeze(1)) if interface_num == 0: total_reflectivity_matrix = reflection_matrix else: - translation_matrix = _make_translation_matrix( - k_z_previous_layer, thickness_prev_layer - ) + translation_matrix = _make_translation_matrix(k_z_previous_layer, thickness_prev_layer) total_reflectivity_matrix = torch.einsum( "bnmr, bmlr, bljr -> bnjr", @@ -81,15 +70,11 @@ def _get_relative_k_z(k_z0, scattering_length_density): def _make_reflection_matrix(k_z_previous_layer, k_z_next_layer, interface_roughness): - p = _safe_div( - (k_z_previous_layer + k_z_next_layer), (2 * k_z_previous_layer) - ) * torch.exp( + p = _safe_div((k_z_previous_layer + k_z_next_layer), (2 * k_z_previous_layer)) * torch.exp( -((k_z_previous_layer - k_z_next_layer) ** 2) * 0.5 * interface_roughness**2 ) - m = _safe_div( - (k_z_previous_layer - k_z_next_layer), (2 * k_z_previous_layer) - ) * torch.exp( + m = _safe_div((k_z_previous_layer - k_z_next_layer), (2 * k_z_previous_layer)) * torch.exp( -((k_z_previous_layer + k_z_next_layer) ** 2) * 0.5 * interface_roughness**2 ) @@ -116,6 +101,4 @@ def _make_translation_matrix(k_z, thickness): def _safe_div(numerator, denominator): - return torch.where( - denominator == 0, numerator, torch.divide(numerator, denominator) - ) + return torch.where(denominator == 0, numerator, torch.divide(numerator, denominator)) diff --git a/src/reflecto_exp/simulate/torch_engine/model_utils.py b/src/reflecto_exp/simulate/torch_engine/model_utils.py index c821b3c..5ee03bc 100644 --- a/src/reflecto_exp/simulate/torch_engine/model_utils.py +++ b/src/reflecto_exp/simulate/torch_engine/model_utils.py @@ -26,14 +26,10 @@ def param_names(self) -> tuple[str]: def simulate_reflectivity(self, q: Tensor, theta: Tensor) -> Tensor: raise NotImplementedError - def get_sld_profiles( - self, theta: Tensor, z: Tensor | None = None - ) -> tuple[Tensor, Tensor]: + def get_sld_profiles(self, theta: Tensor, z: Tensor | None = None) -> tuple[Tensor, Tensor]: raise NotImplementedError - def scale_theta_with_q_factor( - self, theta: Tensor, q: float - ) -> tuple[Tensor, Tensor]: + def scale_theta_with_q_factor(self, theta: Tensor, q: float) -> tuple[Tensor, Tensor]: raise NotImplementedError @property @@ -75,13 +71,7 @@ def __init__( self.use_background = enable_background self.min_background = min_background if not enable_background else 0.0 self.dq_q = dq_q - self._theta_dim = ( - 3 * num_layers - + 2 - + int(enable_background) - + int(enable_q_misalignment) - + int(enable_r_misalignment) - ) + self._theta_dim = 3 * num_layers + 2 + int(enable_background) + int(enable_q_misalignment) + int(enable_r_misalignment) def _theta2refl_kwargs(self, theta: Tensor): assert theta.shape[-1] == self._theta_dim @@ -140,9 +130,7 @@ def log_bkg_idx(self) -> int | None: def sld_indices(self) -> tuple[int]: return tuple(range(2 * self.num_layers + 1, 3 * self.num_layers + 2)) - def get_sld_profiles( - self, theta: Tensor, z: Tensor = None, **kwargs - ) -> tuple[Tensor, Tensor]: + def get_sld_profiles(self, theta: Tensor, z: Tensor = None, **kwargs) -> tuple[Tensor, Tensor]: params = self._theta2refl_kwargs(theta) return get_density_profiles( thicknesses=params["thickness"], @@ -166,16 +154,12 @@ def param_names(self) -> tuple[str]: if self.use_background: additional_labels.append(r"$log(bkg)$") - return tuple( - get_param_labels(self.num_layers, add_units=True) + additional_labels - ) + return tuple(get_param_labels(self.num_layers, add_units=True) + additional_labels) def simulate_reflectivity(self, q: Tensor, theta: Tensor) -> Tensor: return simulate_reflectivity(q, **self._theta2refl_kwargs(theta)) - def scale_theta_with_q_factor( - self, theta: Tensor, q_factor: float - ) -> tuple[Tensor, Tensor]: + def scale_theta_with_q_factor(self, theta: Tensor, q_factor: float) -> tuple[Tensor, Tensor]: theta = theta.clone() logdetjac = -torch.ones_like(theta[..., 0]) * log(q_factor) diff --git a/src/reflecto_exp/simulate/torch_engine/numpy_implementations.py b/src/reflecto_exp/simulate/torch_engine/numpy_implementations.py index 2118454..adb36fc 100644 --- a/src/reflecto_exp/simulate/torch_engine/numpy_implementations.py +++ b/src/reflecto_exp/simulate/torch_engine/numpy_implementations.py @@ -14,7 +14,7 @@ def abeles_np( roughness: np.ndarray, sld: np.ndarray, ): - c_dtype = np.complex128 if q.dtype is np.float64 else np.complex64 + c_dtype = np.complex128 if q.dtype == np.float64 else np.complex64 if q.ndim == thickness.ndim == roughness.ndim == sld.ndim == 1: zero_batch = True @@ -27,12 +27,8 @@ def abeles_np( batch_size, num_layers = thickness.shape - sld = np.concatenate([np.zeros((batch_size, 1)).astype(sld.dtype), sld], -1)[ - :, None - ] - thickness = np.concatenate( - [np.zeros((batch_size, 1)).astype(thickness.dtype), thickness], -1 - )[:, None] + sld = np.concatenate([np.zeros((batch_size, 1)).astype(sld.dtype), sld], -1)[:, None] + thickness = np.concatenate([np.zeros((batch_size, 1)).astype(thickness.dtype), thickness], -1)[:, None] roughness = roughness[:, None] ** 2 sld = sld * 1e-6 + 1e-30j @@ -106,20 +102,11 @@ def kinematical_approximation_np( if q.ndim == 2: q = q[..., None] - drho = np.concatenate([sld[..., 0][..., None], sld[..., 1:] - sld[..., :-1]], -1)[ - :, None - ] - thickness = np.cumsum( - np.concatenate([np.zeros((batch_size, 1)), thickness], -1), -1 - )[:, None] + drho = np.concatenate([sld[..., 0][..., None], sld[..., 1:] - sld[..., :-1]], -1)[:, None] + thickness = np.cumsum(np.concatenate([np.zeros((batch_size, 1)), thickness], -1), -1)[:, None] roughness = roughness[:, None] - r = ( - np.abs( - (drho * np.exp(-((roughness * q) ** 2) / 2 + 1j * (q * thickness))).sum(-1) - ).astype(float) - ** 2 - ) + r = np.abs((drho * np.exp(-((roughness * q) ** 2) / 2 + 1j * (q * thickness))).sum(-1)).astype(float) ** 2 rf = _get_resnel_reflectivity_np(q, substrate_sld[:, None]) diff --git a/src/reflecto_exp/simulate/torch_engine/refl_utils.py b/src/reflecto_exp/simulate/torch_engine/refl_utils.py index b1a1d1d..bbe6146 100644 --- a/src/reflecto_exp/simulate/torch_engine/refl_utils.py +++ b/src/reflecto_exp/simulate/torch_engine/refl_utils.py @@ -64,9 +64,7 @@ def get_density_profiles( sigmas = roughnesses * sqrt(2) - profiles = ambient + _get_erf( - z_axis[:, None], zs[..., None], sigmas[..., None], d_rhos[..., None] - ).sum(1) + profiles = ambient + _get_erf(z_axis[:, None], zs[..., None], sigmas[..., None], d_rhos[..., None]).sum(1) # d_profiles = _get_gauss(z_axis[:, None], zs[..., None], sigmas[..., None], d_rhos[..., None]).sum(1) @@ -88,9 +86,7 @@ def get_reversed_params(thicknesses: Tensor, roughnesses: Tensor, slds: Tensor): ) reversed_thicknesses = torch.flip(thicknesses, [-1]) reversed_roughnesses = torch.flip(roughnesses, [-1]) - reversed_params = torch.cat( - [reversed_thicknesses, reversed_roughnesses, reversed_slds], -1 - ) + reversed_params = torch.cat([reversed_thicknesses, reversed_roughnesses, reversed_slds], -1) return reversed_params @@ -126,20 +122,14 @@ def _label_str(i: int, label_name: str, units: str) -> str: label = label + " " + unit_template % units return label - thickness_labels = [ - _label_str(i, thickness_name, thickness_units) for i in range(num_layers) - ] - roughness_labels = [ - _label_str(i, roughness_name, roughness_units) for i in range(num_layers + 1) - ] + thickness_labels = [_label_str(i, thickness_name, thickness_units) for i in range(num_layers)] + roughness_labels = [_label_str(i, roughness_name, roughness_units) for i in range(num_layers + 1)] sld_labels = [_label_str(i, sld_name, sld_units) for i in range(num_layers + 1)] return thickness_labels + roughness_labels + sld_labels -def get_max_allowed_roughness( - thicknesses: Tensor, total_max_roughness: Tensor, coef: float = 1.0 -) -> Tensor: +def get_max_allowed_roughness(thicknesses: Tensor, total_max_roughness: Tensor, coef: float = 1.0) -> Tensor: """ Calculate max allowed roughness for each layer based on thicknesses and total max roughness. diff --git a/src/reflecto_exp/simulate/torch_engine/smearing.py b/src/reflecto_exp/simulate/torch_engine/smearing.py index f8f67c3..ae41844 100644 --- a/src/reflecto_exp/simulate/torch_engine/smearing.py +++ b/src/reflecto_exp/simulate/torch_engine/smearing.py @@ -56,11 +56,7 @@ def abeles_constant_smearing( def _batch_linspace(start: Tensor, end: Tensor, num: int): - return ( - torch.linspace(0, 1, int(num), device=end.device, dtype=end.dtype)[None] - * (end - start) - + start - ) + return torch.linspace(0, 1, int(num), device=end.device, dtype=end.dtype)[None] * (end - start) + start def _torch_gauss(x, s): @@ -69,16 +65,11 @@ def _torch_gauss(x, s): def _get_t_gauss_kernels(resolutions: Tensor, gaussnum: int = 51): gauss_x = _batch_linspace(-1.7 * resolutions, 1.7 * resolutions, gaussnum) - gauss_y = ( - _torch_gauss(gauss_x, resolutions / _FWHM) - * (gauss_x[:, 1] - gauss_x[:, 0])[:, None] - ) + gauss_y = _torch_gauss(gauss_x, resolutions / _FWHM) * (gauss_x[:, 1] - gauss_x[:, 0])[:, None] return gauss_y -def _get_q_axes( - q: Tensor, resolutions: Tensor, gaussnum: int = 51, xrr_dq: bool = True -): +def _get_q_axes(q: Tensor, resolutions: Tensor, gaussnum: int = 51, xrr_dq: bool = True): if xrr_dq: return _get_q_axes_for_constant_dq(q, resolutions, gaussnum) else: @@ -94,30 +85,20 @@ def _get_q_axes_for_linear_dq(q: Tensor, resolutions: Tensor, gaussnum: int = 51 start = torch.log10(lowq) - 6 * resolutions / _FWHM end = torch.log10(highq * (1 + 6 * resolutions / _FWHM)) - interpnums = ( - torch.abs((torch.abs(end - start)) / (1.7 * resolutions / _FWHM / gaussgpoint)) - .round() - .to(int) - ) + interpnums = torch.abs((torch.abs(end - start)) / (1.7 * resolutions / _FWHM / gaussgpoint)).round().to(int) q_lin = 10 ** _batch_linspace_with_padding(start, end, interpnums) return q_lin -def _get_q_axes_for_constant_dq( - q: Tensor, resolutions: Tensor, gaussnum: int = 51 -) -> Tensor: +def _get_q_axes_for_constant_dq(q: Tensor, resolutions: Tensor, gaussnum: int = 51) -> Tensor: gaussgpoint = (gaussnum - 1) / 2 start = q.min(1).values[:, None] - resolutions * 1.7 end = q.max(1).values[:, None] + resolutions * 1.7 - interpnums = ( - torch.abs((torch.abs(end - start)) / (1.7 * resolutions / gaussgpoint)) - .round() - .to(int) - ) + interpnums = torch.abs((torch.abs(end - start)) / (1.7 * resolutions / gaussgpoint)).round().to(int) q_lin = _batch_linspace_with_padding(start, end, interpnums) q_lin = torch.clamp_min_(q_lin, 1e-6) @@ -130,9 +111,7 @@ def _batch_linspace_with_padding(start: Tensor, end: Tensor, nums: Tensor) -> Te deltas = 1 / (nums - 1) - x = torch.clamp_min_( - _batch_linspace(deltas * (nums - max_num), torch.ones_like(deltas), max_num), 0 - ) + x = torch.clamp_min_(_batch_linspace(deltas * (nums - max_num), torch.ones_like(deltas), max_num), 0) x = x * (end - start) + start @@ -146,16 +125,9 @@ def _batch_linear_interp1d(x: Tensor, y: Tensor, x_new: Tensor) -> Tensor: ind = torch.clamp_(ind - 1, 0, x.shape[-1] - 2) slopes = (y[..., 1:] - y[..., :-1]) / (eps + (x[..., 1:] - x[..., :-1])) - ind_y = ( - ind + torch.arange(slopes.shape[0], device=slopes.device)[:, None] * y.shape[1] - ) - ind_slopes = ( - ind - + torch.arange(slopes.shape[0], device=slopes.device)[:, None] * slopes.shape[1] - ) - - y_new = y.flatten()[ind_y] + slopes.flatten()[ind_slopes] * ( - x_new - x.flatten()[ind_y] - ) + ind_y = ind + torch.arange(slopes.shape[0], device=slopes.device)[:, None] * y.shape[1] + ind_slopes = ind + torch.arange(slopes.shape[0], device=slopes.device)[:, None] * slopes.shape[1] + + y_new = y.flatten()[ind_y] + slopes.flatten()[ind_slopes] * (x_new - x.flatten()[ind_y]) return y_new diff --git a/tests/test_abeles.py b/tests/test_abeles.py new file mode 100644 index 0000000..3a20f48 --- /dev/null +++ b/tests/test_abeles.py @@ -0,0 +1,70 @@ +import numpy as np +import pytest + +torch = pytest.importorskip("torch") + +from reflecto_exp.simulate.torch_engine.abeles import abeles # noqa: E402 +from reflecto_exp.simulate.torch_engine.numpy_implementations import abeles_np # noqa: E402 + +Q = np.linspace(0.005, 0.3, 120) + + +def parratt(q, thickness, roughness, sld): + """Reflectivity by the Parratt recursion; SLD in 1e-6 A^-2, vacuum above the first layer. + + Written from the recursion itself so it shares no code with either engine. + """ + rho = np.concatenate([[0.0], sld]) * 1e-6 + kz = [np.sqrt((q / 2) ** 2 - 4 * np.pi * r + 0j) for r in rho] + amplitude = None + for j in range(len(rho) - 2, -1, -1): + r = (kz[j] - kz[j + 1]) / (kz[j] + kz[j + 1]) * np.exp(-2 * kz[j] * kz[j + 1] * roughness[j] ** 2) + if amplitude is None: + amplitude = r + else: + phase = np.exp(2j * kz[j + 1] * thickness[j]) + amplitude = (r + amplitude * phase) / (1 + r * amplitude * phase) + return np.abs(amplitude) ** 2 + + +STACKS = [ + ([120.0, 30.0], [3.0, 2.0, 4.0], [40.0, 10.0, 20.0]), + ([250.0], [0.0, 3.0], [45.0, 20.0]), + ([40.0], [1.0, 1.0], [10.0, 20.0]), +] + + +@pytest.mark.parametrize("thickness, roughness, sld", STACKS) +def test_numpy_engine_matches_the_parratt_recursion_in_double_precision(thickness, roughness, sld): + got = abeles_np(Q, np.array([thickness]), np.array([roughness]), np.array([sld]))[0] + np.testing.assert_allclose(got, parratt(Q, thickness, roughness, sld), rtol=1e-9, atol=1e-14) + + +@pytest.mark.parametrize("thickness, roughness, sld", STACKS) +def test_torch_engine_matches_the_parratt_recursion(thickness, roughness, sld): + # Tolerance 1e-5: near a critical edge the torch engine is off by up to 4e-7 relative. + got = abeles(Q_T, *(torch.tensor([x]) for x in (thickness, roughness, sld))).numpy()[0] + np.testing.assert_allclose(got, parratt(Q, thickness, roughness, sld), rtol=1e-5, atol=1e-12) + + +Q_T = torch.tensor(Q) + + +def test_engines_agree_on_a_batch(): + thickness = [[100.0], [250.0], [40.0]] + roughness = [[2.0, 5.0], [0.0, 3.0], [1.0, 1.0]] + sld = [[30.0, 20.0], [45.0, 20.0], [10.0, 20.0]] + got = abeles(Q_T, torch.tensor(thickness), torch.tensor(roughness), torch.tensor(sld)).numpy() + want = abeles_np(Q, np.array(thickness), np.array(roughness), np.array(sld)) + assert got.shape == want.shape == (3, Q.size) + np.testing.assert_allclose(got, want, rtol=1e-5, atol=1e-12) + + +def test_total_reflection_below_the_critical_q_and_decay_above(): + # One bare layer of SLD 30e-6 / A^2 on a substrate of the same SLD: a single Fresnel interface. + # Critical q is 4 sqrt(pi * rho) = 4 * sqrt(pi * 30e-6) = 0.0388 A^-1. + got = abeles(Q_T, torch.tensor([[0.0]]), torch.tensor([[0.0, 0.0]]), torch.tensor([[30.0, 30.0]])).numpy() + qc = 4 * np.sqrt(np.pi * 30e-6) + assert np.all(got[0, Q < 0.9 * qc] > 0.999) + assert np.all(got[0, Q > 3 * qc] < 1e-2) + assert np.all(got <= 1.0) diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py new file mode 100644 index 0000000..95f4d91 --- /dev/null +++ b/tests/test_run_paths.py @@ -0,0 +1,42 @@ +"""Evaluation scripts read the run their own main.py trained (ReflectoExp#3). + +The scripts run training or plotting under ``if __name__ == "__main__"`` and need data on disk, so +this checks the one line that picks the folder: it must come from the experiment's CONFIG, the same +place main.py takes it from, and not from a path hard-coded to another experiment. +""" + +import importlib.util +from pathlib import Path + +import pytest + +RUNS = Path(__file__).resolve().parents[1] / "runs" +EXPERIMENTS = ["exp02_one_layer", "exp04_one_genx"] +SCRIPTS = ["evaluate.py", "evaluate_with_real_data.py"] + + +@pytest.mark.parametrize("exp", EXPERIMENTS) +@pytest.mark.parametrize("script", SCRIPTS) +def test_evaluate_script_takes_the_run_folder_from_config(exp, script): + text = (RUNS / exp / script).read_text(encoding="utf-8") + assert 'CONFIG["base_dir"] / CONFIG["exp_name"]' in text + # The folder another experiment trained into, which these scripts used to read. + assert r'r"XRR_AI\data\one_layer\run"' not in text + + +@pytest.mark.parametrize("exp", EXPERIMENTS) +def test_main_trains_into_the_same_folder(exp): + text = (RUNS / exp / "main.py").read_text(encoding="utf-8") + assert 'CONFIG["base_dir"] / CONFIG["exp_name"]' in text + + +@pytest.mark.parametrize("exp", EXPERIMENTS) +def test_config_defines_the_run_folder(exp, monkeypatch, tmp_path): + # exp02 builds base_dir from $DATA; exp04 writes a fixed path. Either way it must be a Path. + monkeypatch.setenv("DATA", str(tmp_path)) + spec = importlib.util.spec_from_file_location(f"{exp}_config", RUNS / exp / "config.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + run_dir = module.CONFIG["base_dir"] / module.CONFIG["exp_name"] + assert run_dir.parent == module.CONFIG["base_dir"] + assert run_dir.name == module.CONFIG["exp_name"] diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..035a86c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,70 @@ +import warnings + +import numpy as np +import pytest + +from reflecto_exp.io_utils import append_timestamp, load_xrr_dat, next_unique_file +from reflecto_exp.math_utils import fom_log, i0_normalize, powerspace +from reflecto_exp.physics_utils import tth2q + + +def test_powerspace_keeps_endpoints_and_reduces_to_linspace_for_power_one(): + x = powerspace(2.0, 8.0, 7, power=1.0) + np.testing.assert_allclose(x, np.linspace(2.0, 8.0, 7)) + y = powerspace(2.0, 8.0, 7, power=1.5) + assert y[0] == 2.0 and y[-1] == 8.0 + assert np.all(np.diff(y) > 0) + + +def test_fom_log_is_the_mean_decade_difference(): + assert fom_log(np.array([1.0, 10.0]), np.array([10.0, 100.0])) == pytest.approx(1.0) + assert fom_log(np.array([3.0, 5.0]), np.array([3.0, 5.0])) == 0.0 + + +def test_i0_normalize_scales_the_maximum_to_one(): + out = i0_normalize(np.array([2.0, 4.0, 8.0])) + np.testing.assert_allclose(out, [0.25, 0.5, 1.0]) + + +def test_i0_normalize_warns_and_returns_zeros_unchanged(): + zeros = np.zeros(3) + with pytest.warns(RuntimeWarning): + out = i0_normalize(zeros) + np.testing.assert_array_equal(out, zeros) + + +def test_tth2q_matches_the_bragg_relation(): + # q = 4 pi sin(theta) / lambda; 2theta = 60 deg gives theta = 30 deg + assert tth2q(60.0, 1.54) == pytest.approx(4 * np.pi * 0.5 / 1.54) + assert isinstance(tth2q(60.0), float) + arr = tth2q(np.array([0.0, 60.0]), 1.54) + assert isinstance(arr, np.ndarray) and arr[0] == 0.0 + + +def test_next_unique_file_numbers_after_the_highest_existing(tmp_path): + base = tmp_path / "out.txt" + assert next_unique_file(base) == base + base.write_text("a") + assert next_unique_file(base) == tmp_path / "out(1).txt" + (tmp_path / "out(1).txt").write_text("b") + (tmp_path / "out(4).txt").write_text("c") + assert next_unique_file(base) == tmp_path / "out(5).txt" + + +def test_append_timestamp_keeps_the_suffix(tmp_path): + with_suffix = append_timestamp(tmp_path / "scan.dat") + assert with_suffix.parent == tmp_path and with_suffix.suffix == ".dat" + assert with_suffix != tmp_path / "scan.dat" + no_suffix = append_timestamp(tmp_path / "scan") + assert no_suffix.parent == tmp_path and no_suffix.suffix == "" + assert no_suffix.name.startswith("scan_") + + +def test_load_xrr_dat_skips_comments_reads_scientific_notation_and_zeros_garbage(tmp_path): + f = tmp_path / "x.dat" + f.write_text("# header\n0.5 1.0e-05\n1.0\t2.5E-3 # trailing comment\n1.5 oops\n") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + tth, r = load_xrr_dat(f) + np.testing.assert_allclose(tth, [0.5, 1.0, 1.5]) + np.testing.assert_allclose(r, [1.0e-05, 2.5e-3, 0.0]) diff --git a/uv.lock b/uv.lock index 4cfa2fe..ac00f70 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,13 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "sys_platform != 'darwin'", + "sys_platform == 'darwin'", +] + +[manifest] +overrides = [{ name = "wxpython", marker = "sys_platform != 'linux'" }] [[package]] name = "aiohappyeyeballs" @@ -905,7 +912,7 @@ dependencies = [ { name = "requests" }, { name = "scipy" }, { name = "svgwrite" }, - { name = "wxpython" }, + { name = "wxpython", marker = "sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/b0/eab50e24059139f3dd363d8d21f9cffeea557a5d9b58e291483e4be3f4e1/genx3-3.7.13.tar.gz", hash = "sha256:34c8dc84a1ae2ddabfa92fe46647e5af096ede9b5c34cd86e4ee6443e6d0984a", size = 8749356, upload-time = "2025-07-23T14:37:48.591Z" } wheels = [ @@ -2406,7 +2413,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2415,9 +2422,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -2611,9 +2618,12 @@ dependencies = [ { name = "scikit-learn" }, { name = "scipy" }, { name = "seaborn" }, - { name = "torch" }, - { name = "torchaudio" }, - { name = "torchvision" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.14.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "torchaudio", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "torchaudio", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.29.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "tqdm" }, ] @@ -2646,6 +2656,7 @@ test = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -2684,15 +2695,21 @@ requires-dist = [ { name = "seaborn" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=5.0.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2.0" }, - { name = "torch", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchaudio", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchvision", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torch", marker = "sys_platform != 'darwin'", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torch", marker = "sys_platform == 'darwin'" }, + { name = "torchaudio", marker = "sys_platform != 'darwin'", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchaudio", marker = "sys_platform == 'darwin'" }, + { name = "torchvision", marker = "sys_platform != 'darwin'", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", marker = "sys_platform == 'darwin'" }, { name = "tqdm" }, ] provides-extras = ["colab", "dev", "test", "docs"] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.14.3" }] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.14.3" }, +] [[package]] name = "requests" @@ -3077,6 +3094,9 @@ wheels = [ name = "torch" version = "2.11.0+cu128" source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "sys_platform != 'darwin'", +] dependencies = [ { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, @@ -3111,10 +3131,51 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314t-win_amd64.whl", hash = "sha256:d86c125d720c2c368c53bd1a4ef062916d91fa965c10448c74c78b5d039faf2d", upload-time = "2026-04-27T18:01:14Z" }, ] +[[package]] +name = "torch" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/3c/379349820e9a6e713826de3d0b88f07728541bdcabe2a3afd128871cdc4b/torch-2.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:420dbf314c180ee4b86e9bc00aee5746a7d6e5bacdd7df925af671ed393f0b2e", size = 127307099, upload-time = "2026-09-30T17:44:10.421Z" }, + { url = "https://files.pythonhosted.org/packages/7d/11/faaca4f8541c45127b7e0d6bb141221fe944c8466d89986b8466c6c195f9/torch-2.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dbe359d705f4d67236743794c296c6dee93a922fd8117eff8c3e880d7d0fb2b9", size = 127315286, upload-time = "2026-09-30T17:52:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/19/23a1aed488423a5055727256b25406e4b93bd2bcf1352bef582b9951c10c/torch-2.14.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:cee091caf2a6229e248daf41d18ceebf590ba02d9179205610062c64ee5fef03", size = 127325330, upload-time = "2026-09-30T17:52:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/2d/bc/1afbd1a22f6023eafcdb9295ac1d97137a7816f54091d693701072da2218/torch-2.14.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:06c3ec25f3b497f9a73dc0c36f293f295cfe8d5584fd446238d9a501b30a66d5", size = 127657364, upload-time = "2026-09-30T17:53:53.837Z" }, +] + +[[package]] +name = "torchaudio" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform == 'darwin'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b1/77658817acacd01a72b714440c62f419efc4d90170e704e8e7a2c0918988/torchaudio-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1cf1acc883bee9cb906a933572fed6a8a933f86ef34e9ea7d803f72317e8c1b", size = 684226, upload-time = "2026-03-23T18:13:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9e/f76fcd9877c8c78f258ee34e0fb8291fdb91e6218d582d9ca66b1e4bd4ae/torchaudio-2.11.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e3f9696a9ef1d49acc452159b052370c636406d072e9d8f10895fda87b591ea9", size = 679904, upload-time = "2026-03-23T18:13:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ce/52c652d30af7d6e96c8f1735d26131e94708e3f38d852b8fa97958804dd8/torchaudio-2.11.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:bda09ea630ae7207384fb0f28c35e4f8c0d82dd6eba020b6b335ad0caa9fed49", size = 680814, upload-time = "2026-03-23T18:13:17.08Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/ffa618b4f0d9732d7df7a2fa2bd48657d896599bc224e5af3c70d46c546b/torchaudio-2.11.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:cc09cd1f6015b8549e7fe255fb1be5346b57e7fee06541d3f3dbb012d8c4715f", size = 679901, upload-time = "2026-03-23T18:13:25.472Z" }, + { url = "https://files.pythonhosted.org/packages/60/84/1c792b0b700eac9a96772cfd9f96c097b17bca3234a2fde3c64b8063660d/torchaudio-2.11.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:da2725e250866da42a12934c9a6552f65a18b7187fd7a6221387f0e605fb3b96", size = 679926, upload-time = "2026-03-23T18:13:24.452Z" }, +] + [[package]] name = "torchaudio" version = "2.11.0+cu128" source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "sys_platform != 'darwin'", +] wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torchaudio-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d055c9ca9d4f0ffcbaa0fc22138bafd675256de392bdaadde00d797faa90ca56", upload-time = "2026-03-23T15:50:23Z" }, { url = "https://download-r2.pytorch.org/whl/cu128/torchaudio-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:78b86a17f164bdaabdcee93fdfde2587fc43b9ebf15cd61dcf730b4f8615176b", upload-time = "2026-03-23T15:50:23Z" }, @@ -3137,10 +3198,13 @@ wheels = [ name = "torchvision" version = "0.26.0+cu128" source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "sys_platform != 'darwin'", +] dependencies = [ { name = "numpy" }, { name = "pillow" }, - { name = "torch" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:63e35234aed13b6edda37056f417b5c281249669db631e706811917af36b21d7", upload-time = "2026-04-09T23:21:35Z" }, @@ -3160,6 +3224,25 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314t-win_amd64.whl", hash = "sha256:f160dc552a086244f7102c898f7be8ef46a41b36bce5ea80a4f2493cb30ca1fc", upload-time = "2026-04-09T23:21:41Z" }, ] +[[package]] +name = "torchvision" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch", version = "2.14.1", source = { registry = "https://pypi.org/simple" } }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/f8/855c7b326ed17a8dac4a03e8e6fec33ef6728e4baa00d816cf1227bb6681/torchvision-0.29.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3b6eb9d2f8fe9b93d9d37637749df361efeed133d96ddd503184a1b5b12bb27f", size = 1813847, upload-time = "2026-09-30T17:57:01.009Z" }, + { url = "https://files.pythonhosted.org/packages/8b/85/33819e5ed0caf494a5c0caa6c72bcd9d5bd22c68480d636288832d9d5f55/torchvision-0.29.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b0b3de76f2ee5678b7950ba9971cf72d9c5887cd7993db61788552f371686fed", size = 1813847, upload-time = "2026-09-30T17:57:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/41d167245bc87808eacb730042142649ac22a565200b1ce843462e954f37/torchvision-0.29.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c33cbd596afa13c24de752ead437eb5da48285efd6dc2a06543948821bfde57a", size = 1813852, upload-time = "2026-09-30T17:57:21.646Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f8/5133682a7e5e7e052f7fd9e5457a61364601096708b7c1d37462653d1d7b/torchvision-0.29.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8b26ff2c61cd1da60d74cf704cae6007bc38902a60d4a7068fe327db1cb45c62", size = 1813854, upload-time = "2026-09-30T17:57:28.127Z" }, +] + [[package]] name = "tornado" version = "6.5.2"