modified: .DS_Store

modified:   checkpoints_final/task1_final/task1_final_model.pkl
	new file:   checkpoints_final/task1_final/task1_tmd_adapter.pkl
	new file:   scripts_4/README.md
	new file:   scripts_4/__init__.py
	new file:   scripts_4/__pycache__/adapter.cpython-314.pyc
	new file:   scripts_4/adapter.py
	new file:   scripts_4/predict_tmd_single.py
	new file:   scripts_4/train_tmd_adapter.py
This commit is contained in:
CrbnsCat10n
2026-05-06 20:57:43 +08:00
parent f3766c74d6
commit 79f3de05f7
9 changed files with 390 additions and 0 deletions

BIN
.DS_Store vendored

Binary file not shown.

Binary file not shown.

45
scripts_4/README.md Normal file
View File

@@ -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`

2
scripts_4/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Task4: Tiny-sample TMD adaptation on top of baseline RMS model."""

Binary file not shown.

101
scripts_4/adapter.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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()