fix(stats): CV 改用 |mean|,并记录决策 37/38/39 与经验 12

coefficient_of_variation:
- 分母由 mean 改为 |mean|(用户本人改),均值为负时 CV 不再是负值
- docstring 摘要 / Returns / Notes 同步,加一条测试钉住"均值为负时 CV 仍为正"

口径拍板(2026-10-03):
- 决策 37: 非数值列的处置按输出契约分,不按包分 —— describe/zscore 丢掉(吐数值结果表),
  detect_outliers 保留列、掩码恒 False(吐与原表同形的掩码),刻意不统一
- 决策 38: CV 用 sigma/|mean|
- 决策 39: 工作约定 —— 语义/非法输入口径由用户拍板,Codex 只列选项不代选;
  ruff / STATUS.md / CHANGELOG.md / tests / docstring 这类与算法无关的工程事务 Codex 顺手补

经验(docs/dev/personal-pitfalls.md,只追加):
- 第 12 条: 三个并列的 if 让后面的静默覆盖前面的(transform.normalize 三种 method 实际只走 max)
  第 11 条(上一轮)是 descriptive 的 .loc 对齐失败静默填 NaN —— 两条的共同点是"不抛异常"

验证: pytest 103 passed / 39 skipped;ruff All checks passed;pyright 0 errors。

docs: STATUS.md 决策 37/38/39 + 下一步;CHANGELOG 记录
This commit is contained in:
guzhujushi committed 2026-10-03 13:26:13 +08:00
1 parent e9624cb3a7
commit cfc1be31ca
5 files changed
+58 -20

No files matched your search

+7 -3
View File
@@ -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` 的用法届时并进去。
- 已知缺口:
+15 -11
View File
@@ -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)✓
+21
View File
@@ -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
+6 -6
View File
@@ -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))
+9
View File
@@ -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