From f99cf5c3c940df8045d35c5929b03d9711a37b80 Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Tue, 20 Oct 2026 08:47:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BA=86=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + STATUS.md | 30 +++++++-- src/mcm_toolkit/viz/export.py | 112 +++++++++++++++++++++++++++++++--- tests/test_viz.py | 105 ++++++++++++++++++++++++++++--- 4 files changed, 228 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c38d4..b9a2d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### Added +- `viz.save_figure`:L0 最后一项落地(Codex 写)。入口校验先于副作用,支持 `png` / `pdf` / `svg` / `eps` / `ps` / `jpg` / `jpeg` / `tif` / `tiff` / `webp`;`path` 带图片后缀时自动去后缀。`tests/test_viz.py` 去掉模块级 skip 并新增 8 条保存测试。 - 仓库骨架:`src/mcm_toolkit/` 7 个包(core / data / stats / models / solve / decision / viz) - 顶层 `__init__.py` 只导出 7 个子包 + 8 个快捷函数,`__all__` 即公开 API 清单 - `core.Result`:唯一的结果对象(替代原建议里的 5 个 Result 类) diff --git a/STATUS.md b/STATUS.md index 3592e8d..dbeaff2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # STATUS — 当前进度 -> 更新日期:2026-10-12 +> 更新日期:2026-10-18 ## 本次任务 @@ -18,12 +18,12 @@ - A 档**薄封装**(`rmse`/`mae`/`fit_linear`/`train_test_split`…)算工程,Codex 可直接实现。 - 分地盘按**文件**划,不按函数划(同一文件两人各写一半曾导致用户编辑器保存失败)。 -当前 **L0 11/12**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 +当前 **L0 12/12(已全部完成)**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 `detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、 `monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` / `monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、 `markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。 -剩余 1 项:`save_figure`(Codex 写)。 +`save_figure` 已于 2026-10-18 由 Codex 落地,L0 收官。 **`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` / `adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地 (含指标名**别名映射**)。`models` 下一步是 `regression.py`。 @@ -428,11 +428,11 @@ 只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。 - [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。 note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。 -- [ ] L0 完成 **11/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 +- [x] L0 完成 **12/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 `detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、 `monte_carlo`(用户写,2026-10-04));另 L1 的 `confidence_interval` / `t_test` / `monte_carlo_probability` / `monte_carlo_integral` 已提前落地; - **L0 只剩 `save_figure`(Codex 写)**,详情见 E 节与"下一步" + **L0 已全部完成**(`save_figure` 于 2026-10-18 落地),详情见"下一步"第十一轮 - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** (`helper_unknown_error: setup refresh had errors`),只有提权 (`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。 @@ -650,6 +650,26 @@ Codex 侧:把这三条口径写进 7 处 `Raises`(**又是易错点 30**: 测试 23 → 31 条:首列排序(升序 / 降序两条)、别名、坏指标名、坏 `models` / `cv`、每折样本数下限、`random_state` 可复现。 ## 下一步 +### ⭐ 当前进度与待办(2026-10-18 第十一轮) + +**本轮:L0 收官 —— `save_figure` 落地(Codex 写,A 档)。** + +- `viz/export.py`:实现 `save_figure`。入口先校验 `fig` / `dpi` / `formats` / `bbox_inches` / + `transparent` / `path`,**全部校验通过后才建目录、写文件**;`formats` 支持大小写归一, + 空 / 未知 / 重复格式一律报错;`path` 带图片后缀时自动去后缀(不会写出 `fig1.png.png`)。 +- `tests/test_viz.py`:去掉违反决策 26 的模块级 `pytestmark`,改为逐条 skip; + `test_save_figure_writes_png_and_pdf` 不再依赖未实现的 `plot()`,改用 `plt.subplots()` 构造图。 + 新增 8 条:后缀剥离、自动建目录、格式归一与顺序、单个字符串 / 空 / 未知 / 重复格式、 + 校验先于副作用、坏 `dpi` / `bbox_inches` / `transparent` / 非 `Figure`。 +- 三连:`pytest` **242 passed / 24 skipped**、`ruff` **0**、`pyright` **0**。 + +**下一步** +1. `models/regression.py` 的 `fit_linear` / `fit_polynomial`(A 档)。**开工前先定返回契约**: + `Result` 没有 `fit/predict/score`,与 `compare_models` 要求 `Model` 协议的入参对不上, + 需要定成"`Result.data` 放已拟合模型 + `compare_models` 接受两种入参"之类的桥接方案。 +2. `decision/entropy_weights`(S 档,用户写);Codex 先补函数卡片 / 测试骨架。 +3. 集中拍板 `STATUS.md` 里挂着的小口径(`t_test` / `as_array` / `bootstrap_ci` / `markov_*` / `mm1` / 死代码)。 + ### ⭐ 当前进度与待办(2026-10-12 第十轮) **本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。** diff --git a/src/mcm_toolkit/viz/export.py b/src/mcm_toolkit/viz/export.py index d274cd7..2b7e832 100644 --- a/src/mcm_toolkit/viz/export.py +++ b/src/mcm_toolkit/viz/export.py @@ -9,36 +9,132 @@ from __future__ import annotations from pathlib import Path from typing import Any +import numpy as np +from matplotlib.figure import Figure + __all__ = ["save_figure", "save_table"] +_ALLOWED_FORMATS = frozenset({"png", "pdf", "svg", "eps", "ps", "jpg", "jpeg", "tif", "tiff", "webp"}) + + +def _normalize_formats(formats: Any) -> tuple[str, ...]: + """校验并规范化输出格式;重复、空列表、单个字符串都在入口拦下。""" + if isinstance(formats, str): + raise TypeError("formats 必须是 tuple / list,不要传单个字符串(会把 'png' 拆成 p/n/g)") + if not isinstance(formats, (tuple, list)): + raise TypeError(f"formats 必须是 tuple / list,收到 {type(formats).__name__}") + if not formats: + raise ValueError("formats 不能为空") + + normalized: list[str] = [] + for fmt in formats: + if not isinstance(fmt, str): + raise TypeError(f"formats 里的每一项都必须是字符串,收到 {type(fmt).__name__}") + key = fmt.lower().lstrip(".") + if key not in _ALLOWED_FORMATS: + allowed = ", ".join(sorted(_ALLOWED_FORMATS)) + raise ValueError(f"不支持的图片格式:{fmt!r};可选:{allowed}") + if key in normalized: + raise ValueError(f"formats 里有重复的格式:{key!r}") + normalized.append(key) + return tuple(normalized) + def save_figure( fig: Any, path: str | Path, - dpi: int = 300, - formats: tuple[str, ...] = ("png", "pdf"), - bbox_inches: str = "tight", + dpi: int | np.integer = 300, + formats: tuple[str, ...] | list[str] = ("png", "pdf"), + bbox_inches: str | None = "tight", transparent: bool = False, ) -> list[Path]: """保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。 Parameters ---------- + fig: + `matplotlib.figure.Figure`(`plt.subplots()` 的第一个返回值)。 + path: + 文件名**主干**。若带了 `png` / `pdf` 等图片后缀,会先去掉后缀, + 再按 `formats` 逐个生成;父目录不存在时自动创建。 + dpi: + 输出分辨率,默认 300 —— 美赛送稿的 DPI 底线。 formats: - 美赛论文如果用 LaTeX 排版,**矢量图(PDF/EPS)比 PNG 清晰得多**, - 所以默认两种都出。 + 要写的格式,tuple / list,大小写不敏感,顺序决定返回顺序。 + 支持 png / pdf / svg / eps / ps / jpg / jpeg / tif / tiff / webp。 + bbox_inches: + 传给 `Figure.savefig`;默认 `"tight"` 去掉多余白边,`None` 用 matplotlib 默认值。 + transparent: + 是否透明背景。正文用图一般保持 `False`,叠加图例或封面图可以用 `True`。 Returns ------- - 实际写出的文件路径列表。 + 实际写出的文件路径列表,顺序与 `formats` 一致。 + + Raises + ------ + TypeError + `fig` 不是 `Figure`;`formats` 不是 tuple / list、含非字符串项; + `dpi` / `transparent` / `bbox_inches` 类型不对。 + ValueError + `formats` 为空、含不支持的格式或重复格式;`dpi <= 0`; + `path` 为空或是一个目录。 Notes ----- + 所有校验都在建目录、写文件**之前**完成,传错格式不会留下空目录。 建议配合项目的 figure 目录约定:`figures/fig{序号}_{短名}.pdf`, 图号与正文引用一一对应,最后核对时不容易错位。 + + Examples + -------- + >>> fig, ax = plt.subplots() # doctest: +SKIP + >>> paths = save_figure(fig, "figures/fig1") + >>> [p.suffix for p in paths] + ['.png', '.pdf'] """ - # TODO(L0) - raise NotImplementedError + if not isinstance(fig, Figure): + raise TypeError(f"fig 必须是 matplotlib 的 Figure,收到 {type(fig).__name__}") + + if isinstance(dpi, bool) or not isinstance(dpi, (int, np.integer)): + raise TypeError(f"dpi 必须是正整数,收到 {type(dpi).__name__}") + if dpi <= 0: + raise ValueError(f"dpi 必须大于 0,收到 {dpi}") + + if not isinstance(transparent, bool): + raise TypeError(f"transparent 必须是 bool,收到 {type(transparent).__name__}") + + if bbox_inches is not None: + if not isinstance(bbox_inches, str): + raise TypeError(f"bbox_inches 必须是字符串或 None,收到 {type(bbox_inches).__name__}") + if not bbox_inches.strip(): + raise ValueError("bbox_inches 不能是空字符串") + + if not isinstance(path, (str, Path)): + raise TypeError(f"path 必须是 str 或 Path,收到 {type(path).__name__}") + base = Path(path) + if not base.name: + raise ValueError(f"path 不能为空:{path!r}") + if base.is_dir(): + raise ValueError(f"path 是一个目录,请给文件名主干:{base}") + if base.suffix and base.suffix.lower().lstrip(".") in _ALLOWED_FORMATS: + base = base.with_suffix("") + + normalized = _normalize_formats(formats) + base.parent.mkdir(parents=True, exist_ok=True) + + written: list[Path] = [] + for fmt in normalized: + target = base.with_suffix(f".{fmt}") + fig.savefig( + target, + dpi=int(dpi), + bbox_inches=bbox_inches, + transparent=transparent, + format=fmt, + ) + written.append(target) + return written def save_table( diff --git a/tests/test_viz.py b/tests/test_viz.py index de1884a..0be7a3d 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -1,6 +1,7 @@ """viz 包测试。 绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。 +未实现的函数按决策 26 **逐条** skip,不用模块级 `pytestmark`。 """ from __future__ import annotations @@ -11,18 +12,30 @@ matplotlib.use("Agg") import pytest -pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0/L1") +_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1" +@pytest.fixture +def fig(): + """最小 Figure;测试结束统一关闭,避免 matplotlib 的"图开太多"告警。""" + import matplotlib.pyplot as plt + + figure, _ = plt.subplots(figsize=(2, 2)) + yield figure + plt.close(figure) + + +@pytest.mark.skip(reason=_NOT_IMPLEMENTED) @pytest.mark.parametrize("kind", ["line", "scatter", "hist", "box", "bar"]) def test_plot_kinds_produce_figure(kind, rng): from mcm_toolkit.viz import plot x = rng.normal(size=100) - fig, ax = plot(x, x, kind=kind) - assert fig is not None and ax is not None + figure, ax = plot(x, x, kind=kind) + assert figure is not None and ax is not None +@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_mcm_style_sets_300dpi(): from mcm_toolkit.viz import mcm_style @@ -31,15 +44,93 @@ def test_mcm_style_sets_300dpi(): assert matplotlib.rcParams["font.size"] >= 8.0 -def test_save_figure_writes_png_and_pdf(tmp_path, rng): - from mcm_toolkit.viz import plot, save_figure +def test_save_figure_writes_png_and_pdf(fig, tmp_path): + from mcm_toolkit.viz import save_figure - fig, _ = plot(rng.normal(size=50), rng.normal(size=50), kind="scatter") paths = save_figure(fig, tmp_path / "fig1", formats=("png", "pdf")) - assert {p.suffix for p in paths} == {".png", ".pdf"} + assert [p.suffix for p in paths] == [".png", ".pdf"] assert all(p.exists() and p.stat().st_size > 0 for p in paths) +def test_save_figure_strips_an_image_suffix_from_path(fig, tmp_path): + """`fig1.png` 当主干用:不能写出 `fig1.png.png`。""" + from mcm_toolkit.viz import save_figure + + paths = save_figure(fig, tmp_path / "fig1.png", formats=("pdf",)) + assert paths == [tmp_path / "fig1.pdf"] + assert paths[0].exists() + + +def test_save_figure_creates_parent_directories(fig, tmp_path): + from mcm_toolkit.viz import save_figure + + target = tmp_path / "nested" / "deeper" / "fig1" + paths = save_figure(fig, target, formats=("png",)) + assert paths[0] == tmp_path / "nested" / "deeper" / "fig1.png" + assert paths[0].exists() + + +def test_save_figure_normalizes_format_case_and_keeps_order(fig, tmp_path): + from mcm_toolkit.viz import save_figure + + paths = save_figure(fig, tmp_path / "fig1", formats=("PDF", "png")) + assert [p.suffix for p in paths] == [".pdf", ".png"] + assert all(p.exists() for p in paths) + + +def test_save_figure_rejects_a_single_string_as_formats(fig, tmp_path): + from mcm_toolkit.viz import save_figure + + with pytest.raises(TypeError, match="不要传单个字符串"): + save_figure(fig, tmp_path / "fig1", formats="png") # type: ignore[arg-type] + + +def test_save_figure_rejects_bad_formats(fig, tmp_path): + from mcm_toolkit.viz import save_figure + + with pytest.raises(ValueError, match="不能为空"): + save_figure(fig, tmp_path / "fig1", formats=()) + with pytest.raises(ValueError, match="不支持"): + save_figure(fig, tmp_path / "fig1", formats=("gif",)) + with pytest.raises(ValueError, match="重复"): + save_figure(fig, tmp_path / "fig1", formats=("png", "PNG")) + + +def test_save_figure_validates_before_creating_directories(fig, tmp_path): + """格式错时不能先建目录留下副作用。""" + from mcm_toolkit.viz import save_figure + + nested = tmp_path / "should_not_exist" / "fig1" + with pytest.raises(ValueError, match="不支持"): + save_figure(fig, nested, formats=("gif",)) + assert not nested.parent.exists() + + +def test_save_figure_rejects_bad_render_options(fig, tmp_path): + from mcm_toolkit.viz import save_figure + + with pytest.raises(TypeError, match="dpi"): + save_figure(fig, tmp_path / "fig1", dpi=300.0) # type: ignore[arg-type] + with pytest.raises(TypeError, match="dpi"): + save_figure(fig, tmp_path / "fig1", dpi=True) + with pytest.raises(ValueError, match="dpi"): + save_figure(fig, tmp_path / "fig1", dpi=0) + with pytest.raises(TypeError, match="bbox_inches"): + save_figure(fig, tmp_path / "fig1", bbox_inches=3) # type: ignore[arg-type] + with pytest.raises(ValueError, match="bbox_inches"): + save_figure(fig, tmp_path / "fig1", bbox_inches="") + with pytest.raises(TypeError, match="transparent"): + save_figure(fig, tmp_path / "fig1", transparent="yes") # type: ignore[arg-type] + + +def test_save_figure_rejects_non_figure(tmp_path): + from mcm_toolkit.viz import save_figure + + with pytest.raises(TypeError, match="Figure"): + save_figure(object(), tmp_path / "fig1") # type: ignore[arg-type] + + +@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_save_table_writes_latex_with_caption(tmp_path): import pandas as pd