diff --git a/CHANGELOG.md b/CHANGELOG.md index df17051..7b1bdea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,10 +164,13 @@ - `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError` (那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning); 报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。 +- `stats.coefficient_of_variation`:分母由 `mean` 改为 `|mean|`(**用户本人改的**,决策 38)—— + 均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小";现在 CV 恒非负。docstring 的 `Notes` 同步, + 并加一条测试钉住"均值为负时 CV 仍为正"。 - `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试 **全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。 改为逐条 skip 未实现的 5 条。 -- 测试计数:**82 passed / 40 skipped → 102 passed / 39 skipped**(新增 20 条); +- 测试计数:**82 passed / 40 skipped → 103 passed / 39 skipped**(新增 21 条); ruff 7 error → **All checks passed**;pyright **1 error → 0**。 ### Notes @@ -181,8 +184,9 @@ 另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。 - 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。 - `stats` 这条线开工了:`describe` 落地,并填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`。 -- 还余**三处口径待用户拍板**:`log_transform` 的显式 `offset`、`zscore` 丢掉非数值列、 - `coefficient_of_variation` 在均值为负时返回负值。见 `STATUS.md`「下一步」第 4 条与决策 35/36。 +- 三处口径已拍板(2026-10-03):`log_transform` 的 `offset` 维持现状(决策 35); + `describe` / `zscore` **默认丢掉**非数值列、与 `detect_outliers` 的"保留列"刻意不统一(决策 37); + 变异系数改用 `σ/|μ|`(决策 38)。另立决策 39 明确分工:语义口径用户拍板、工程事务 Codex 顺手补。 - 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`, `Result` 的用法届时并进去。 - 已知缺口: diff --git a/STATUS.md b/STATUS.md index cddba99..d26da51 100644 --- a/STATUS.md +++ b/STATUS.md @@ -148,7 +148,7 @@ (原先和为 0 时裸抛 `ZeroDivisionError`) - `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`; `coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、 - 空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字 + 空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字;分母改用 `|mean|`(**用户本人定**,见决策 38) - pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0 - 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 —— 决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条, @@ -160,7 +160,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **102 passed, 39 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **103 passed, 39 skipped(0 xfail)** | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | | `ruff check` | All checks passed | **All checks passed**(0 error) | | `scripts/doctor.ps1` | 跑通 | — | @@ -170,8 +170,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **141 条用例:102 passed / 39 skipped**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 25、 +- **142 条用例:103 passed / 39 skipped**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 + `test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 26、 `test_models.py` 5 - **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正, 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 @@ -242,6 +242,12 @@ | 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 | | 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 | | 36 | `describe` 的返回表**不转置**:行 = 指标、列 = 变量 | +| 37 | 非数值列的处置**按输出契约分**,不按包分:`describe` / `zscore` 直接丢掉;`detect_outliers` 保留列、掩码恒 `False` | +两族函数的产出物不一样:`describe` / `zscore` 吐的是**数值结果表**,文本列塞不进去,丢掉才能得到一张干净、可直接贴论文的表;`detect_outliers` 吐的是**与原表同形的掩码**,必须能按位置对回原表,丢列就废了。"不统一"不是前后矛盾,是契约不同。用户 2026-10-03 拍板:丢掉更好 | +| 38 | `coefficient_of_variation` 用 `σ / |mean|`(**用户本人改的**) | +均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小",其实只是符号跟着均值走。取绝对值后 CV 恒非负。可比性仍只在均值远离 0 的比率尺度数据上成立(已写进 docstring 的 Notes) | +| 39 | 工作约定:**语义 / 非法输入口径由用户拍板,Codex 只列选项与影响、不代选**;**ruff、`STATUS.md`、`CHANGELOG.md`、`tests/`、docstring 这类与算法无关的工程事务,Codex 顺手补掉** | +用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" | docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 | 原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 | ## 下一步 @@ -252,10 +258,8 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 2. 之后按 L0 剩余顺序推进:`bootstrap_ci`(**S 档,要写函数卡片**)→ `monte_carlo`(**S 档**) → `save_figure`(A)。 3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 -4. **三处口径待你拍板**(Codex 只列选项,不替你选): - - **决策 35**:`log_transform` 的显式 `offset` 按"定义域前提"校验(`min(x) + offset > 0`)。 - 若原意是"只收正 offset",说一声我改回去。 - - **`zscore(df)` 遇到非数值列会静默丢掉那一列**(`describe` 也一样只描述数值列)。 - `detect_outliers` 走的是"保留列、掩码恒 False"(决策 32)—— 要不要统一? - - **`coefficient_of_variation` 在均值为负时返回负值**(`σ/μ` 原样输出)。 - 备选:① 取绝对值 `σ/|μ|`;② 均值为负直接抛 `ValueError`;③ 维持现状 + Notes 提醒(我暂时按 ③ 写的)。 +4. **三处口径已拍板**(2026-10-03): + - `log_transform` 的显式 `offset`:**按现状**(定义域前提校验,决策 35)✓ + - 非数值列:`describe` / `zscore` **默认丢掉**,与 `detect_outliers` 的"保留列、掩码恒 False" + **不统一** —— 两者输出契约不同,理由见决策 37 ✓ + - 变异系数:已改用 `σ / |μ|`(决策 38)✓ diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 971f4b8..8b53418 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -177,3 +177,24 @@ ② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了"; ③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。 - 首次:2026-10-03|复发:0 + +### 12. 三个并列的 `if`,后面的静默覆盖前面的 + +- 现象:`normalize` 里三种 method 写成了**三个并列的 `if`**(而不是 `if / elif / else`): + `if method == "minmax": result = ...`、`if method == "vector": result = ...`, + 末尾再来一句无条件的 `result = arr / x_max` 兜住 max。 + 读起来像"三种方法各算各的",实际是**三段顺序执行、逐次覆盖**同一个 `result`: + 不管传 minmax 还是 vector,算完都被最后那段冲掉 —— 三种方法**实际只走了 max**。 + docstring 里那行 `>>> normalize([1, 2, 3]).tolist()` → `[0.0, 0.5, 1.0]` 就是活规格, + 实测出来是 `[0.33, 0.67, 1.0]`,5 条测试一起红。 +- 为什么会错:脑子里想的是"按 method 分派",手上写成了"按顺序执行"。 + 写第一个 `if` 的时候没意识到"少了 `elif` 就没有互斥",而 `result` 又是同一个变量 —— + `if` 只会让**条件为真时**赋值,`elif`/`else` 才负责让**其余分支不执行**。 + 这类错**不报错、不抛异常**,只有断言能抓。 +- 以后怎么防:① 同一个变量被多个分支赋值时,一律 `if / elif / else`,或让每个分支 `return`, + 绝不写并列 `if`;② 分派逻辑写完先跑最朴素的那个用例(docstring 的 Examples 就是最好的靶子), + 别等全量测试;③ 注意 ruff 的 `RET505` 抓的是**反方向**(`return` 后还有 `elif`), + 并列 `if` 覆盖它完全管不着 —— **lint 不会替你发现这个**。 +- 同一轮的另一个同类例子见第 11 条(`.loc` 对齐失败静默填 NaN): + 这两个 bug 的共同点是"**不抛异常**",靠的是断言而不是报错。 +- 首次:2026-10-03|复发:0 diff --git a/src/mcm_toolkit/stats/descriptive.py b/src/mcm_toolkit/stats/descriptive.py index dc5b3f7..26151ab 100644 --- a/src/mcm_toolkit/stats/descriptive.py +++ b/src/mcm_toolkit/stats/descriptive.py @@ -173,7 +173,7 @@ def zscore(x: Any, ddof: int = 0) -> Any: def coefficient_of_variation(x: Any) -> float: - """变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。 + """变异系数 `std / |mean|` —— 跨量纲比较"谁更波动"时的唯一正确做法。 Parameters ---------- @@ -184,7 +184,7 @@ def coefficient_of_variation(x: Any) -> float: Returns ------- float - `std / mean`,标准差按总体口径(`ddof=0`)。 + `std / |mean|`,标准差按总体口径(`ddof=0`)。恒非负。 Raises ------ @@ -195,9 +195,9 @@ def coefficient_of_variation(x: Any) -> float: Notes ----- - CV 只有在**均值为正**的比率尺度数据上才有意义:均值为负时本函数返回负值, - 那不是"波动更小",只是符号跟着均值走;均值接近 0 时 CV 会被放大到任意大。 - 这两类数据请换用别的离散度指标。 + 分母取的是 `|mean|`:均值为负时 CV 仍是正数,不会因为符号反过来被误读成"波动更小"。 + 但 CV 的**可比性**仍然只在均值远离 0 的比率尺度数据上成立 —— 均值接近 0 时 + CV 会被放大到任意大,那种数据请换用别的离散度指标。 """ allowed_type = (list, tuple, np.ndarray, pd.Series) if isinstance(x, pd.DataFrame): @@ -220,4 +220,4 @@ def coefficient_of_variation(x: Any) -> float: if mean == 0: raise ValueError("均值为 0,无法计算变异系数(分母为 0)") - return float(std / mean) + return np.abs(float(std / mean)) diff --git a/tests/test_stats.py b/tests/test_stats.py index 1593ffd..efe4876 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -221,6 +221,15 @@ def test_coefficient_of_variation_accepts_single_column_frame(): assert coefficient_of_variation(df) == pytest.approx(single) +def test_coefficient_of_variation_uses_absolute_mean(): + """均值为负时 CV 仍是**正数**:符号不该被读成"波动更小"。""" + from mcm_toolkit.stats import coefficient_of_variation + + x = np.array([-1.0, -2.0, -3.0, -4.0]) + assert coefficient_of_variation(x) == pytest.approx(x.std() / abs(x.mean())) + assert coefficient_of_variation(x) > 0 + + def test_coefficient_of_variation_zero_mean_is_value_error(): """均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。""" from mcm_toolkit.stats import coefficient_of_variation