diff --git a/.DS_Store b/.DS_Store index e677bc4..c9da38d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/checkpoints_final/task1_final/task1_final_model.pkl b/checkpoints_final/task1_final/task1_final_model.pkl index e8c9240..73c026b 100644 Binary files a/checkpoints_final/task1_final/task1_final_model.pkl and b/checkpoints_final/task1_final/task1_final_model.pkl differ diff --git a/checkpoints_final/task1_final/task1_tmd_adapter.pkl b/checkpoints_final/task1_final/task1_tmd_adapter.pkl new file mode 100644 index 0000000..f1515b5 Binary files /dev/null and b/checkpoints_final/task1_final/task1_tmd_adapter.pkl differ diff --git a/scripts_4/README.md b/scripts_4/README.md new file mode 100644 index 0000000..5a70e7f --- /dev/null +++ b/scripts_4/README.md @@ -0,0 +1,45 @@ +# scripts_4: TMD 小样本适配 + +本目录实现二阶段方案: + +1. 基础模型(`Non_TMD` 训练得到的 `task1_final_model.pkl`)先给出基线预测; +2. 用极少量 `TMD` 样本拟合频率相关比例系数 `k(f)`; +3. 输出 `TMD` 预测:`y_tmd = k(f) * y_base`。 + +## 为什么这样做 + +- 仅 5 个 TMD 点直接重训主模型容易过拟合; +- 保留基础模型泛化能力,只学习域偏移; +- 通过 `shrinkage` 把校正系数向 `1.0` 收缩,降低小样本波动影响; +- 频率外推时会自动向 `1.0` 回归,避免越界夸张。 + +## 脚本说明 + +- `train_tmd_adapter.py`:拟合并保存适配器,同时输出 LOOCV 报告。 +- `predict_tmd_single.py`:加载基础模型 + 适配器,对单个 TMD 文件推理。 +- `adapter.py`:频率校正器定义与序列化工具。 + +## 使用方法(请使用虚拟环境 Python) + +```bash +.venv/bin/python scripts_4/train_tmd_adapter.py +``` + +```bash +.venv/bin/python scripts_4/predict_tmd_single.py \ + --file downloads/TMD/val/harmonic_5mm_1.25Hz_TMD.csv +``` + +可选参数示例: + +```bash +.venv/bin/python scripts_4/train_tmd_adapter.py \ + --shrinkage 0.25 \ + --extrapolation-decay-hz 0.3 +``` + +## 输出文件 + +- 适配器:`checkpoints_final/task1_final/task1_tmd_adapter.pkl` +- 报告:`evaluation_outputs/task1_final/tmd_adapter_report.json` + diff --git a/scripts_4/__init__.py b/scripts_4/__init__.py new file mode 100644 index 0000000..98351d4 --- /dev/null +++ b/scripts_4/__init__.py @@ -0,0 +1,2 @@ +"""Task4: Tiny-sample TMD adaptation on top of baseline RMS model.""" + diff --git a/scripts_4/__pycache__/adapter.cpython-314.pyc b/scripts_4/__pycache__/adapter.cpython-314.pyc new file mode 100644 index 0000000..b565b2a Binary files /dev/null and b/scripts_4/__pycache__/adapter.cpython-314.pyc differ diff --git a/scripts_4/adapter.py b/scripts_4/adapter.py new file mode 100644 index 0000000..80d0834 --- /dev/null +++ b/scripts_4/adapter.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import pickle + +import numpy as np + + +def _safe_float(value: float) -> float: + return float(np.asarray(value, dtype=np.float64).reshape(-1)[0]) + + +@dataclass +class FrequencyScaleAdapter: + frequencies_hz: np.ndarray + scale_values: np.ndarray + shrinkage: float = 0.2 + extrapolation_decay_hz: float = 0.25 + min_scale: float = 0.4 + max_scale: float = 2.5 + + def __post_init__(self) -> None: + freqs = np.asarray(self.frequencies_hz, dtype=np.float64).reshape(-1) + scales = np.asarray(self.scale_values, dtype=np.float64).reshape(-1) + if freqs.size == 0: + raise ValueError("frequencies_hz cannot be empty.") + if freqs.size != scales.size: + raise ValueError("frequencies_hz and scale_values must have the same length.") + order = np.argsort(freqs) + freqs = freqs[order] + scales = np.clip(scales[order], self.min_scale, self.max_scale) + # Pull scales toward 1.0 to reduce overfitting with tiny TMD samples. + scales = 1.0 + (scales - 1.0) * (1.0 - float(np.clip(self.shrinkage, 0.0, 0.95))) + self.frequencies_hz = freqs + self.scale_values = scales + + def _inside_range_weight(self, frequency_hz: float) -> float: + left = self.frequencies_hz[0] + right = self.frequencies_hz[-1] + f = _safe_float(frequency_hz) + if left <= f <= right: + return 1.0 + distance = min(abs(f - left), abs(f - right)) + decay = max(_safe_float(self.extrapolation_decay_hz), 1e-6) + return float(np.exp(-distance / decay)) + + def scale_at(self, frequency_hz: float) -> float: + f = _safe_float(frequency_hz) + boundary_interp = float(np.interp(f, self.frequencies_hz, self.scale_values)) + in_range_weight = self._inside_range_weight(f) + scale = 1.0 + in_range_weight * (boundary_interp - 1.0) + return float(np.clip(scale, self.min_scale, self.max_scale)) + + def predict(self, base_rms: float, frequency_hz: float) -> float: + return max(_safe_float(base_rms), 0.0) * self.scale_at(frequency_hz) + + def to_payload(self) -> dict: + return { + "frequencies_hz": self.frequencies_hz.tolist(), + "scale_values": self.scale_values.tolist(), + # scale_values are already post-shrinkage values after __post_init__. + "shrinkage": 0.0, + "extrapolation_decay_hz": float(self.extrapolation_decay_hz), + "min_scale": float(self.min_scale), + "max_scale": float(self.max_scale), + "already_shrunk": True, + } + + @classmethod + def from_payload(cls, payload: dict) -> "FrequencyScaleAdapter": + shrinkage = float(payload.get("shrinkage", 0.2)) + if bool(payload.get("already_shrunk", False)): + shrinkage = 0.0 + return cls( + frequencies_hz=np.asarray(payload["frequencies_hz"], dtype=np.float64), + scale_values=np.asarray(payload["scale_values"], dtype=np.float64), + shrinkage=shrinkage, + extrapolation_decay_hz=float(payload.get("extrapolation_decay_hz", 0.25)), + min_scale=float(payload.get("min_scale", 0.4)), + max_scale=float(payload.get("max_scale", 2.5)), + ) + + +def save_adapter(adapter: FrequencyScaleAdapter, output_path: Path, extra: dict | None = None) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "adapter_type": "frequency_scale_interp_v1", + "adapter": adapter.to_payload(), + "extra": extra or {}, + } + with output_path.open("wb") as handle: + pickle.dump(payload, handle) + + +def load_adapter(adapter_path: Path) -> tuple[FrequencyScaleAdapter, dict]: + with adapter_path.open("rb") as handle: + payload = pickle.load(handle) + adapter = FrequencyScaleAdapter.from_payload(payload["adapter"]) + extra = payload.get("extra", {}) + return adapter, extra diff --git a/scripts_4/predict_tmd_single.py b/scripts_4/predict_tmd_single.py new file mode 100644 index 0000000..441f9c2 --- /dev/null +++ b/scripts_4/predict_tmd_single.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import argparse +import importlib.util +import pickle +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +try: + from .adapter import load_adapter +except ImportError: + from adapter import load_adapter + + +def _load_module(module_name: str, file_path: Path): + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module from {file_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Predict TMD RMS using baseline model + frequency adapter.") + parser.add_argument("--file", type=str, required=True) + parser.add_argument("--checkpoint", type=str, default="checkpoints_final/task1_final/task1_final_model.pkl") + parser.add_argument("--adapter", type=str, default="checkpoints_final/task1_final/task1_tmd_adapter.pkl") + parser.add_argument("--data-dir", type=str, default="downloads/TMD") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + config_module = _load_module("config", PROJECT_ROOT / "scripts" / "config.py") + dataset_module = _load_module("task1_dataset", PROJECT_ROOT / "scripts" / "dataset.py") + make_experiment_config = getattr(config_module, "make_experiment_config") + build_record_from_file = getattr(dataset_module, "build_record_from_file") + + config = make_experiment_config() + config.data.data_dir = args.data_dir + config.data.__post_init__() + + ckpt_path = (PROJECT_ROOT / args.checkpoint).resolve() + adapter_path = (PROJECT_ROOT / args.adapter).resolve() + with ckpt_path.open("rb") as handle: + payload = pickle.load(handle) + model = payload["model"] + adapter, extra = load_adapter(adapter_path) + + file_path = Path(args.file).resolve() + record = build_record_from_file(file_path, config.data) + + pred_tr = max(float(model.predict([record.features.tolist()])[0]), config.data.normalization_eps) + pred_base = pred_tr * record.x_rms + pred_tmd = adapter.predict(pred_base, record.frequency_hz) + + true_rms = float(record.y_rms) + base_err = abs(pred_base - true_rms) / max(abs(true_rms), 1e-12) * 100.0 + tmd_err = abs(pred_tmd - true_rms) / max(abs(true_rms), 1e-12) * 100.0 + + print(f"Checkpoint: {ckpt_path}") + print(f"Adapter: {adapter_path}") + print(f"Adapter extra: {extra}") + print(f"Input file: {file_path}") + print(f"Frequency (Hz): {record.frequency_hz:.6f}") + print(f"x_rms: {record.x_rms:.6f}") + print(f"True y_rms: {true_rms:.6f}") + print(f"Base pred y_rms: {pred_base:.6f} | error={base_err:.4f}%") + print(f"Adapted pred y_rms: {pred_tmd:.6f} | error={tmd_err:.4f}%") + print(f"Applied scale k(f): {adapter.scale_at(record.frequency_hz):.6f}") + + +if __name__ == "__main__": + main() diff --git a/scripts_4/train_tmd_adapter.py b/scripts_4/train_tmd_adapter.py new file mode 100644 index 0000000..b62a871 --- /dev/null +++ b/scripts_4/train_tmd_adapter.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import argparse +import json +import pickle +from pathlib import Path +import sys + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT / "scripts")) + +from config import make_experiment_config +from dataset import build_record_from_file + +try: + from .adapter import FrequencyScaleAdapter, save_adapter +except ImportError: + from adapter import FrequencyScaleAdapter, save_adapter + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fit a tiny-sample TMD adapter on top of the Non_TMD baseline .pkl model.") + parser.add_argument("--checkpoint", type=str, default=None, help="Path to baseline model .pkl.") + parser.add_argument("--tmd-dir", type=str, default="downloads/TMD", help="Directory that contains TMD harmonic csv files.") + parser.add_argument("--output", type=str, default="checkpoints_final/task1_final/task1_tmd_adapter.pkl") + parser.add_argument("--report-json", type=str, default="evaluation_outputs/task1_final/tmd_adapter_report.json") + parser.add_argument("--shrinkage", type=float, default=0.20, help="Scale shrinkage toward 1.0.") + parser.add_argument("--extrapolation-decay-hz", type=float, default=0.25) + parser.add_argument("--min-scale", type=float, default=0.40) + parser.add_argument("--max-scale", type=float, default=2.50) + return parser.parse_args() + + +def resolve_checkpoint_path(checkpoint_arg: str | None) -> Path: + if checkpoint_arg: + return Path(checkpoint_arg).resolve() + return PROJECT_ROOT / "checkpoints_final" / "task1_final" / "task1_final_model.pkl" + + +def load_baseline_model(model_path: Path): + with model_path.open("rb") as handle: + payload = pickle.load(handle) + return payload["model"], payload.get("summary", {}) + + +def base_predict_rms(model, features: np.ndarray, x_rms: float, eps: float) -> float: + pred_tr = max(float(model.predict([features.tolist()])[0]), eps) + return pred_tr * float(x_rms) + + +def collect_tmd_rows(model, tmd_dir: Path, eps: float) -> list[dict]: + config = make_experiment_config().data + config.data_dir = str(Path(tmd_dir).relative_to(PROJECT_ROOT)) + config.__post_init__() + rows: list[dict] = [] + for file_path in sorted(config.data_root.rglob("harmonic_5mm_*Hz_TMD.csv")): + record = build_record_from_file(file_path, config) + base_pred = base_predict_rms(model, record.features, record.x_rms, eps) + scale = float(record.y_rms / max(base_pred, eps)) + rows.append( + { + "file_name": file_path.name, + "frequency_hz": float(record.frequency_hz), + "true_y_rms": float(record.y_rms), + "base_pred_y_rms": float(base_pred), + "scale_true_over_base": float(scale), + } + ) + if not rows: + raise RuntimeError("No TMD harmonic files found.") + return rows + + +def loocv_error(rows: list[dict], shrinkage: float, extrapolation_decay_hz: float, min_scale: float, max_scale: float) -> list[float]: + errors: list[float] = [] + eps = 1e-12 + for holdout in range(len(rows)): + train_rows = [row for i, row in enumerate(rows) if i != holdout] + adapter = FrequencyScaleAdapter( + frequencies_hz=np.asarray([row["frequency_hz"] for row in train_rows], dtype=np.float64), + scale_values=np.asarray([row["scale_true_over_base"] for row in train_rows], dtype=np.float64), + shrinkage=shrinkage, + extrapolation_decay_hz=extrapolation_decay_hz, + min_scale=min_scale, + max_scale=max_scale, + ) + sample = rows[holdout] + pred = adapter.predict(sample["base_pred_y_rms"], sample["frequency_hz"]) + err = abs(pred - sample["true_y_rms"]) / max(abs(sample["true_y_rms"]), eps) * 100.0 + errors.append(float(err)) + return errors + + +def main() -> None: + args = parse_args() + ckpt_path = resolve_checkpoint_path(args.checkpoint) + model, baseline_summary = load_baseline_model(ckpt_path) + rows = collect_tmd_rows(model, (PROJECT_ROOT / args.tmd_dir).resolve(), eps=1e-6) + rows = sorted(rows, key=lambda x: x["frequency_hz"]) + + adapter = FrequencyScaleAdapter( + frequencies_hz=np.asarray([row["frequency_hz"] for row in rows], dtype=np.float64), + scale_values=np.asarray([row["scale_true_over_base"] for row in rows], dtype=np.float64), + shrinkage=args.shrinkage, + extrapolation_decay_hz=args.extrapolation_decay_hz, + min_scale=args.min_scale, + max_scale=args.max_scale, + ) + + adapted_rows = [] + for row in rows: + adapted_pred = adapter.predict(row["base_pred_y_rms"], row["frequency_hz"]) + adapted_err = abs(adapted_pred - row["true_y_rms"]) / max(abs(row["true_y_rms"]), 1e-12) * 100.0 + adapted_rows.append({**row, "adapted_pred_y_rms": adapted_pred, "adapted_err_pct": adapted_err}) + + loocv_errors = loocv_error( + rows=rows, + shrinkage=args.shrinkage, + extrapolation_decay_hz=args.extrapolation_decay_hz, + min_scale=args.min_scale, + max_scale=args.max_scale, + ) + report = { + "checkpoint": str(ckpt_path), + "baseline_summary": baseline_summary, + "samples": adapted_rows, + "adapter_scales_after_shrinkage": [ + {"frequency_hz": float(f), "scale": float(s)} + for f, s in zip(adapter.frequencies_hz.tolist(), adapter.scale_values.tolist()) + ], + "metrics": { + "base_mean_err_pct": float( + np.mean( + [ + abs(row["base_pred_y_rms"] - row["true_y_rms"]) / max(abs(row["true_y_rms"]), 1e-12) * 100.0 + for row in rows + ] + ) + ), + "adapted_mean_err_pct": float(np.mean([row["adapted_err_pct"] for row in adapted_rows])), + "loocv_mean_err_pct": float(np.mean(loocv_errors)), + "loocv_max_err_pct": float(np.max(loocv_errors)), + }, + } + + output_path = (PROJECT_ROOT / args.output).resolve() + save_adapter(adapter, output_path, extra={"report_metrics": report["metrics"], "checkpoint": str(ckpt_path)}) + + report_path = (PROJECT_ROOT / args.report_json).resolve() + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"Adapter saved to: {output_path}") + print(f"Report saved to: {report_path}") + print(pd.DataFrame(adapted_rows).to_string(index=False)) + print(f"LOOCV mean error (%): {report['metrics']['loocv_mean_err_pct']:.4f}") + print(f"LOOCV max error (%): {report['metrics']['loocv_max_err_pct']:.4f}") + + +if __name__ == "__main__": + main()