Files
ModelTrainEval/utils/config.py
T
Shaun ZhouandClaude Opus 5 6f00074199 feat(cluster): 新增 resnet18 自监督聚类配置,并修复 --set 的 YAML 标量解析
- 新增 configs/cluster/film_simclr_resnet18.yaml:用 resnet18 做域内自监督后聚类。
  backbone 块带 _replace_,避免 base 里 tiny_conv 的 widths/depths 残留
- parse_override 此前只用 ast.literal_eval,不认 YAML 风格的 null/true/false:
  `--set xxx.enabled=false` 会得到字符串 "false",而非空字符串是真值,开关反被打开。
  改为先试 Python 字面量(保住 1e-3 这类),再退回 YAML 标量解析
- feature_stages 默认由 null 改为 [5, 6]:实测 1 epoch 自监督后 layer2+3 的轮廓
  0.243/稳定性 0.997,明显优于 layer4 的 0.156/0.528。原注释里"自监督后 layer4
  已是域内特征"的推断被数据推翻
- README 补充三种方案的实测对比与成本

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:51:28 +08:00

158 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""YAML 配置加载:支持 `_base_` 继承、深度合并与命令行点号覆盖。"""
from __future__ import annotations
import ast
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
import yaml
BASE_KEY = "_base_"
REPLACE_KEY = "_replace_"
class Config(dict):
"""行为与 dict 一致,额外支持 `cfg.train.lr` 式属性访问。"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
for key, value in list(self.items()):
if isinstance(value, Mapping) and not isinstance(value, Config):
self[key] = Config(value)
def __getattr__(self, item: str) -> Any:
try:
return self[item]
except KeyError as exc: # pragma: no cover - 仅用于更友好的报错
raise AttributeError(item) from exc
def get_path(self, dotted: str, default: Any = None) -> Any:
node: Any = self
for part in dotted.split("."):
if not isinstance(node, Mapping) or part not in node:
return default
node = node[part]
return node
def to_dict(self) -> dict:
def unwrap(value: Any) -> Any:
if isinstance(value, Mapping):
return {k: unwrap(v) for k, v in value.items()}
if isinstance(value, list):
return [unwrap(v) for v in value]
return value
return unwrap(self)
def dump(self, path: str | Path) -> None:
Path(path).write_text(
yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def deep_merge(base: Mapping, override: Mapping) -> dict:
"""递归合并;override 中的标量/列表直接覆盖 base。
子字典默认是**合并**而不是替换,这在换组件时会出问题:把 backbone 从
`tiny_conv` 换成 `torchvision`,base 里 tiny_conv 专有的 widths/depths
会残留下来,传给新组件直接报错。这时在该字典里写 `_replace_: true`,
表示整块替换掉 base 的同名字典。
"""
merged = dict(base)
for key, value in override.items():
if isinstance(value, Mapping) and value.get(REPLACE_KEY):
merged[key] = dict(value) # 整块替换;标记本身留到最后统一剥掉
elif key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
merged[key] = deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def strip_replace_keys(value: Any) -> Any:
"""剥掉所有 `_replace_` 标记,避免它漏进组件的构造参数。
必须等**全部合并做完**再剥:`_load_raw` 对每个 base 都会先 `deep_merge({}, data)`
一次,若在那时就剥掉,标记还没来得及参与真正的替换判断就没了。
"""
if isinstance(value, Mapping):
return {k: strip_replace_keys(v) for k, v in value.items() if k != REPLACE_KEY}
if isinstance(value, list):
return [strip_replace_keys(v) for v in value]
return value
def _load_raw(path: Path, seen: set[Path]) -> dict:
path = path.resolve()
if path in seen:
raise ValueError(f"配置 `{BASE_KEY}` 出现循环引用: {path}")
seen.add(path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise TypeError(f"配置文件必须是映射: {path}")
bases = data.pop(BASE_KEY, [])
if isinstance(bases, str):
bases = [bases]
merged: dict = {}
for base in bases:
base_path = (path.parent / base).resolve()
merged = deep_merge(merged, _load_raw(base_path, set(seen)))
return deep_merge(merged, data)
def parse_override(item: str) -> tuple[str, Any]:
"""解析 `train.lr=1e-3`,把值转成合适的类型。
先按 Python 字面量解析(`1e-3`、`[5, 6]`、`None`、`False` 都能认),
再退回 YAML 标量解析,好让配置文件里的写法在命令行上同样可用
(`null`、`true`、`false`)。两者都失败才保留字符串。
这一步不能只用 `ast.literal_eval`:YAML 风格的 `false` 会原样变成字符串,
而非空字符串是**真值**——`--set xxx.enabled=false` 反而把开关打开了。
也不能只用 YAML:PyYAML 把 `1e-3` 当字符串(YAML 1.1 要写成 `1.0e-3`)。
"""
if "=" not in item:
raise ValueError(f"覆盖项格式应为 key=value,收到: {item}")
key, raw = item.split("=", 1)
raw = raw.strip()
try:
return key.strip(), ast.literal_eval(raw)
except (ValueError, SyntaxError):
pass
try:
parsed = yaml.safe_load(raw)
except yaml.YAMLError:
return key.strip(), raw
# YAML 会把 "abc: def" 之类解析成 dict,命令行上应当按字符串处理
return key.strip(), parsed if isinstance(parsed, (bool, int, float, list, type(None))) else raw
def apply_overrides(cfg: dict, overrides: Iterable[str]) -> dict:
for item in overrides:
key, value = parse_override(item)
parts = key.split(".")
node = cfg
for part in parts[:-1]:
nxt = node.get(part)
if not isinstance(nxt, dict):
nxt = {}
node[part] = nxt
node = nxt
node[parts[-1]] = value
return cfg
def load_config(path: str | Path, overrides: Iterable[str] = ()) -> Config:
cfg = _load_raw(Path(path), set())
cfg = apply_overrides(cfg, overrides)
cfg = strip_replace_keys(cfg)
cfg.setdefault("config_path", str(Path(path).resolve()))
return Config(cfg)