更新了保存图表模块

This commit is contained in:
guzhujushi committed 2026-10-20 08:47:15 +08:00
1 parent 748affb7f0
commit f99cf5c3c9
4 files changed
+228 -20

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
## [Unreleased] ## [Unreleased]
### Added ### 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) - 仓库骨架:`src/mcm_toolkit/` 7 个包(core / data / stats / models / solve / decision / viz)
- 顶层 `__init__.py` 只导出 7 个子包 + 8 个快捷函数,`__all__` 即公开 API 清单 - 顶层 `__init__.py` 只导出 7 个子包 + 8 个快捷函数,`__all__` 即公开 API 清单
- `core.Result`:唯一的结果对象(替代原建议里的 5 个 Result 类) - `core.Result`:唯一的结果对象(替代原建议里的 5 个 Result 类)
+25 -5
View File
@@ -1,6 +1,6 @@
# STATUS — 当前进度 # STATUS — 当前进度
> 更新日期:2026-10-12 > 更新日期:2026-10-18
## 本次任务 ## 本次任务
@@ -18,12 +18,12 @@
- A 档**薄封装**(`rmse`/`mae`/`fit_linear`/`train_test_split`…)算工程,Codex 可直接实现。 - 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`、 `detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
`monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` / `monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` /
`monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、 `monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、
`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。 `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` / **`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` /
`adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地 `adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地
(含指标名**别名映射**)。`models` 下一步是 `regression.py`。 (含指标名**别名映射**)。`models` 下一步是 `regression.py`。
@@ -428,11 +428,11 @@
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。 只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。 - [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。 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`、 `detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
`monte_carlo`(用户写,2026-10-04));另 L1 的 `confidence_interval` / `t_test` / `monte_carlo`(用户写,2026-10-04));另 L1 的 `confidence_interval` / `t_test` /
`monte_carlo_probability` / `monte_carlo_integral` 已提前落地; `monte_carlo_probability` / `monte_carlo_integral` 已提前落地;
**L0 只剩 `save_figure`(Codex 写)**,详情见 E 节与"下一步" **L0 已全部完成**(`save_figure` 于 2026-10-18 落地),详情见"下一步"第十一轮
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
(`helper_unknown_error: setup refresh had errors`),只有提权 (`helper_unknown_error: setup refresh had errors`),只有提权
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。 (`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
@@ -650,6 +650,26 @@ Codex 侧:把这三条口径写进 7 处 `Raises`(**又是易错点 30**:
测试 23 → 31 条:首列排序(升序 / 降序两条)、别名、坏指标名、坏 `models` / `cv`、每折样本数下限、`random_state` 可复现。 测试 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 第十轮) ### ⭐ 当前进度与待办(2026-10-12 第十轮)
**本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。** **本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。**
+104 -8
View File
@@ -9,36 +9,132 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import numpy as np
from matplotlib.figure import Figure
__all__ = ["save_figure", "save_table"] __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( def save_figure(
fig: Any, fig: Any,
path: str | Path, path: str | Path,
dpi: int = 300, dpi: int | np.integer = 300,
formats: tuple[str, ...] = ("png", "pdf"), formats: tuple[str, ...] | list[str] = ("png", "pdf"),
bbox_inches: str = "tight", bbox_inches: str | None = "tight",
transparent: bool = False, transparent: bool = False,
) -> list[Path]: ) -> list[Path]:
"""保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。 """保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。
Parameters Parameters
---------- ----------
fig:
`matplotlib.figure.Figure`(`plt.subplots()` 的第一个返回值)。
path:
文件名**主干**。若带了 `png` / `pdf` 等图片后缀,会先去掉后缀,
再按 `formats` 逐个生成;父目录不存在时自动创建。
dpi:
输出分辨率,默认 300 —— 美赛送稿的 DPI 底线。
formats: 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 Returns
------- -------
实际写出的文件路径列表。 实际写出的文件路径列表,顺序与 `formats` 一致。
Raises
------
TypeError
`fig` 不是 `Figure`;`formats` 不是 tuple / list、含非字符串项;
`dpi` / `transparent` / `bbox_inches` 类型不对。
ValueError
`formats` 为空、含不支持的格式或重复格式;`dpi <= 0`;
`path` 为空或是一个目录。
Notes Notes
----- -----
所有校验都在建目录、写文件**之前**完成,传错格式不会留下空目录。
建议配合项目的 figure 目录约定:`figures/fig{序号}_{短名}.pdf`, 建议配合项目的 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) if not isinstance(fig, Figure):
raise NotImplementedError 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( def save_table(
+98 -7
View File
@@ -1,6 +1,7 @@
"""viz 包测试。 """viz 包测试。
绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。 绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。
未实现的函数按决策 26 **逐条** skip,不用模块级 `pytestmark`。
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,18 +12,30 @@ matplotlib.use("Agg")
import pytest 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"]) @pytest.mark.parametrize("kind", ["line", "scatter", "hist", "box", "bar"])
def test_plot_kinds_produce_figure(kind, rng): def test_plot_kinds_produce_figure(kind, rng):
from mcm_toolkit.viz import plot from mcm_toolkit.viz import plot
x = rng.normal(size=100) x = rng.normal(size=100)
fig, ax = plot(x, x, kind=kind) figure, ax = plot(x, x, kind=kind)
assert fig is not None and ax is not None assert figure is not None and ax is not None
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_mcm_style_sets_300dpi(): def test_mcm_style_sets_300dpi():
from mcm_toolkit.viz import mcm_style from mcm_toolkit.viz import mcm_style
@@ -31,15 +44,93 @@ def test_mcm_style_sets_300dpi():
assert matplotlib.rcParams["font.size"] >= 8.0 assert matplotlib.rcParams["font.size"] >= 8.0
def test_save_figure_writes_png_and_pdf(tmp_path, rng): def test_save_figure_writes_png_and_pdf(fig, tmp_path):
from mcm_toolkit.viz import plot, save_figure 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")) 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) 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): def test_save_table_writes_latex_with_caption(tmp_path):
import pandas as pd import pandas as pd