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:
1 parent
e9624cb3a7
commit
cfc1be31ca
5 files changed
+58
-20
No files matched your search
+7
-3
@@ -164,10 +164,13 @@
|
|||||||
- `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`
|
- `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`
|
||||||
(那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning);
|
(那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning);
|
||||||
报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。
|
报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。
|
||||||
|
- `stats.coefficient_of_variation`:分母由 `mean` 改为 `|mean|`(**用户本人改的**,决策 38)——
|
||||||
|
均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小";现在 CV 恒非负。docstring 的 `Notes` 同步,
|
||||||
|
并加一条测试钉住"均值为负时 CV 仍为正"。
|
||||||
- `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试
|
- `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试
|
||||||
**全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。
|
**全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。
|
||||||
改为逐条 skip 未实现的 5 条。
|
改为逐条 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**。
|
ruff 7 error → **All checks passed**;pyright **1 error → 0**。
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
@@ -181,8 +184,9 @@
|
|||||||
另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
|
另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
|
||||||
- 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
|
- 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
|
||||||
- `stats` 这条线开工了:`describe` 落地,并填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`。
|
- `stats` 这条线开工了:`describe` 落地,并填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`。
|
||||||
- 还余**三处口径待用户拍板**:`log_transform` 的显式 `offset`、`zscore` 丢掉非数值列、
|
- 三处口径已拍板(2026-10-03):`log_transform` 的 `offset` 维持现状(决策 35);
|
||||||
`coefficient_of_variation` 在均值为负时返回负值。见 `STATUS.md`「下一步」第 4 条与决策 35/36。
|
`describe` / `zscore` **默认丢掉**非数值列、与 `detect_outliers` 的"保留列"刻意不统一(决策 37);
|
||||||
|
变异系数改用 `σ/|μ|`(决策 38)。另立决策 39 明确分工:语义口径用户拍板、工程事务 Codex 顺手补。
|
||||||
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`,
|
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`,
|
||||||
`Result` 的用法届时并进去。
|
`Result` 的用法届时并进去。
|
||||||
- 已知缺口:
|
- 已知缺口:
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
(原先和为 0 时裸抛 `ZeroDivisionError`)
|
(原先和为 0 时裸抛 `ZeroDivisionError`)
|
||||||
- `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`;
|
- `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`;
|
||||||
`coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、
|
`coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、
|
||||||
空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字
|
空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字;分母改用 `|mean|`(**用户本人定**,见决策 38)
|
||||||
- pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0
|
- pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0
|
||||||
- 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 ——
|
- 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 ——
|
||||||
决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条,
|
决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条,
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
| 检查 | 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 项 |
|
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
|
||||||
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
||||||
| `scripts/doctor.ps1` | 跑通 | — |
|
| `scripts/doctor.ps1` | 跑通 | — |
|
||||||
@@ -170,8 +170,8 @@
|
|||||||
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
|
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
|
||||||
`docs/04-environment.md` 坑 7)
|
`docs/04-environment.md` 坑 7)
|
||||||
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
|
- 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、
|
- **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` 25、
|
`test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 26、
|
||||||
`test_models.py` 5
|
`test_models.py` 5
|
||||||
- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正,
|
- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正,
|
||||||
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
|
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
|
||||||
@@ -242,6 +242,12 @@
|
|||||||
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
|
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
|
||||||
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 |
|
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 |
|
||||||
| 36 | `describe` 的返回表**不转置**:行 = 指标、列 = 变量 |
|
| 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 |
|
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",改回即可 |
|
原码写的是 `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 档**)
|
2. 之后按 L0 剩余顺序推进:`bootstrap_ci`(**S 档,要写函数卡片**)→ `monte_carlo`(**S 档**)
|
||||||
→ `save_figure`(A)。
|
→ `save_figure`(A)。
|
||||||
3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
4. **三处口径待你拍板**(Codex 只列选项,不替你选):
|
4. **三处口径已拍板**(2026-10-03):
|
||||||
- **决策 35**:`log_transform` 的显式 `offset` 按"定义域前提"校验(`min(x) + offset > 0`)。
|
- `log_transform` 的显式 `offset`:**按现状**(定义域前提校验,决策 35)✓
|
||||||
若原意是"只收正 offset",说一声我改回去。
|
- 非数值列:`describe` / `zscore` **默认丢掉**,与 `detect_outliers` 的"保留列、掩码恒 False"
|
||||||
- **`zscore(df)` 遇到非数值列会静默丢掉那一列**(`describe` 也一样只描述数值列)。
|
**不统一** —— 两者输出契约不同,理由见决策 37 ✓
|
||||||
`detect_outliers` 走的是"保留列、掩码恒 False"(决策 32)—— 要不要统一?
|
- 变异系数:已改用 `σ / |μ|`(决策 38)✓
|
||||||
- **`coefficient_of_variation` 在均值为负时返回负值**(`σ/μ` 原样输出)。
|
|
||||||
备选:① 取绝对值 `σ/|μ|`;② 均值为负直接抛 `ValueError`;③ 维持现状 + Notes 提醒(我暂时按 ③ 写的)。
|
|
||||||
@@ -177,3 +177,24 @@
|
|||||||
② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了";
|
② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了";
|
||||||
③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。
|
③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。
|
||||||
- 首次:2026-10-03|复发:0
|
- 首次: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
|
||||||
@@ -173,7 +173,7 @@ def zscore(x: Any, ddof: int = 0) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def coefficient_of_variation(x: Any) -> float:
|
def coefficient_of_variation(x: Any) -> float:
|
||||||
"""变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。
|
"""变异系数 `std / |mean|` —— 跨量纲比较"谁更波动"时的唯一正确做法。
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -184,7 +184,7 @@ def coefficient_of_variation(x: Any) -> float:
|
|||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
float
|
float
|
||||||
`std / mean`,标准差按总体口径(`ddof=0`)。
|
`std / |mean|`,标准差按总体口径(`ddof=0`)。恒非负。
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
@@ -195,9 +195,9 @@ def coefficient_of_variation(x: Any) -> float:
|
|||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
CV 只有在**均值为正**的比率尺度数据上才有意义:均值为负时本函数返回负值,
|
分母取的是 `|mean|`:均值为负时 CV 仍是正数,不会因为符号反过来被误读成"波动更小"。
|
||||||
那不是"波动更小",只是符号跟着均值走;均值接近 0 时 CV 会被放大到任意大。
|
但 CV 的**可比性**仍然只在均值远离 0 的比率尺度数据上成立 —— 均值接近 0 时
|
||||||
这两类数据请换用别的离散度指标。
|
CV 会被放大到任意大,那种数据请换用别的离散度指标。
|
||||||
"""
|
"""
|
||||||
allowed_type = (list, tuple, np.ndarray, pd.Series)
|
allowed_type = (list, tuple, np.ndarray, pd.Series)
|
||||||
if isinstance(x, pd.DataFrame):
|
if isinstance(x, pd.DataFrame):
|
||||||
@@ -220,4 +220,4 @@ def coefficient_of_variation(x: Any) -> float:
|
|||||||
if mean == 0:
|
if mean == 0:
|
||||||
raise ValueError("均值为 0,无法计算变异系数(分母为 0)")
|
raise ValueError("均值为 0,无法计算变异系数(分母为 0)")
|
||||||
|
|
||||||
return float(std / mean)
|
return np.abs(float(std / mean))
|
||||||
@@ -221,6 +221,15 @@ def test_coefficient_of_variation_accepts_single_column_frame():
|
|||||||
assert coefficient_of_variation(df) == pytest.approx(single)
|
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():
|
def test_coefficient_of_variation_zero_mean_is_value_error():
|
||||||
"""均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。"""
|
"""均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。"""
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
from mcm_toolkit.stats import coefficient_of_variation
|
||||||
|
|||||||
Reference in new issue
Block a user