Files
MCM-ICM_Toolkit/docs/dev/personal-pitfalls.md
T
guzhujushi 95f5266563 feat(stats): correlation_matrix 按方案 A 拆成三个函数,收口返回契约(L0 9/12)
用户 2026-10-03 拍板方案 A(见决策 41),由 Codex 落地。

拆成三个函数:
- correlation_matrix  -> 数值系数矩阵(对角线 1.0)
- correlation_pvalues -> 数值 p 值矩阵(对角线 NaN)
- correlation_table   -> 星标字符串表(对角线 1.000 不带星)
三者共用 _check_corr_args 守卫与成对删缺失口径;correlation_table 在最外层调另外两个。
理由: 字符串表做不了后续计算(挑高相关对、喂聚类/PCA、画热力图),反过来随时能格式化;
      一旦返回字符串,数值信息不可逆地丢失。

顺带修掉两处:
- 对角线 p 值由 0.0 改成 NaN —— 写 0 会被下游读成"极显著",而"自己和自己"没有 p 值
- p 值与 r 现在用同一批观测: 先成对 dropna() 再算,并同样尊重 min_periods。
  此前 corr 是成对删缺失、pearsonr 不删,两者样本量不同,r 与 p 对不上
- p 值只算上三角再镜像(成对变量的 p 天然对称),省一半重复计算

tests: test_correlation_matrix_diagonal_is_one 摘掉 xfail 转正;新增 8 条
  (数值性+对称性、非数值列跳过、p 值对照解析式 t = r*sqrt((n-2)/(1-r^2))、
   对角线 NaN、成对删缺失口径、min_periods、对角线无星标、表格与数值输出一致)。
  测试 105 passed / 38 skipped / 1 xfail -> 114 passed / 38 skipped / 0 xfail。

验证: pytest 114 passed / 38 skipped;ruff All checks passed;pyright 0 errors。

docs: STATUS.md(L0 9/12 + 决策 41 + 下一步);CHANGELOG;
      personal-pitfalls 追加第 14 条(把"能看的表"和"能算的表"塞进同一个返回值);
      roadmap 记录 correlation_matrix 的两个伴随函数
2026-10-03 14:53:05 +08:00

237 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 个人易错点(只追加)
> **本文件的规矩**
> 1. **只追加。** 已有条目一个字都不改、不重排、不润色。发现写错了 → 在末尾补一条"更正",不要回头改。
> 2. **不通读。** 追加前只做两件事:`rg -i "<关键词>" docs/dev/personal-pitfalls.md` 看有没有记过;
> 看一眼下面的索引表最后一行拿编号。全文只在复盘时才读。
> 3. 一条写三行:**现象** / **为什么会错** / **以后怎么防**。
> 写不出"为什么会错"就先别记——那说明还没想清楚。
> 4. 记**规律**,不记事故。"今天又忘了"不新开条目,在已有条目下把 `复发` 加一。
## 追加模板
```markdown
### N. <一句话>
- 现象:
- 为什么会错:
- 以后怎么防:
- 首次:YYYY-MM-DD|复发:0
```
## 索引
| # | 一句话 | 首次 |
|---|---|---|
| 1 | 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量 | 2026-10-01 |
| 2 | 以为 `# TODO(L0)` 只是普通注释 | 2026-10-01 |
| 3 | 签名里的类型注解和 docstring 说的不一致 | 2026-10-01 |
| 4 | 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局 | 2026-10-01 |
| 5 | 参数校验排在副作用(建目录 / 写文件)之后 | 2026-10-01 |
| 6 | 把 lint 的"没报错"当成"没问题" | 2026-10-01 |
---
### 1. 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量
- 现象:`get_rng(np.int64(42))` 不报错,但种子被静默忽略、退回全局流——结果不可复现,且没有任何提示。
- 为什么会错:`np.int64` **不是** python `int` 的子类(`isinstance(np.int64(42), int)` 是 `False`),
但 `np.float64` **是** `float` 的子类。numpy 标量的继承关系不统一,凭直觉一定会翻车。
- 以后怎么防:写类型分支时默认假设"这个值可能来自 numpy"。判断用 `isinstance(x, (int, np.integer))`,
取值统一 `int(x)` 归一化;并且给所有非预期类型加 `raise TypeError` 兜底——
**宁可报错,也不要静默走默认分支**。
- 首次:2026-10-01|复发:0
### 2. 以为 `# TODO(L0)` 只是普通注释
- 现象:函数实现完了,`# TODO(L0)` 还挂在末尾。
- 为什么会错:本仓库把 `TODO(L0)` / `TODO(L1)` / `TODO(v0.2)` 当**进度标记**用:
`docs/03-roadmap.md` 靠它划分档位,`STATUS.md` 靠它统计"还剩哪些没做"。实现完不删,等于骗自己。
- 以后怎么防:实现完成时顺手删掉该函数里的 `TODO(...)`。
全仓还剩多少没做,一条 `rg "TODO\(" src/` 就能数出来。
- 首次:2026-10-01|复发:0
### 3. 签名里的类型注解和 docstring 说的不一致
- 现象:`def get_rng(random_state: int | None = None)`,docstring 却写"已经是 `np.random.Generator` 时原样返回"。
- 为什么会错:类型注解是给**读代码的人和 pyright** 看的契约。注解说只收 int/None、docstring 说还收 Generator,
两处打架时合法用法会被 IDE 标红,真正的错误反而看不出来。
- 以后怎么防:改签名和改 docstring 必须同一次做完。
本项目所有 `random_state` 的注解统一写 `int | np.integer | np.random.Generator | None`。
- 首次:2026-10-01|复发:1
### 4. 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局
- 现象:`set_seed` 换掉了本包的生成器,但 sklearn / statsmodels 里 `random_state=None` 的路径
仍不可复现(测试直接挂)。
- 为什么会错:numpy 有**两套**随机状态——旧的 `np.random.*` 全局单例,和新的 `Generator`。
第三方库大量仍在走旧的那套(`check_random_state(None)`),自己新建一个 Generator 管不到它们。
- 以后怎么防:凡是"设全局状态"的函数,先列清单——自己的、numpy 旧的、标准库 `random` 的、
第三方框架的,逐个决定要不要一起设,并在 docstring 里写清到底覆盖了哪几个。
- 首次:2026-10-01|复发:0
### 5. 参数校验排在副作用(建目录 / 写文件)之后
- 现象:`save_table` 先 `path.parent.mkdir(parents=True, exist_ok=True)`,再在
`if suffix == '.csv' ... else: raise ValueError` 里判断后缀。传 `sub/x.txt` 时,
空目录 `sub\` 已经建出来了,函数才抛 `ValueError`——错误路径上留下垃圾。
- 为什么会错:函数体是照着"顺利流程"顺着写下来的——先建目录、再按分支干活;
校验顺手塞进分支的 `else` 里,于是校验自动排到了最后。分支越多,越会有路径漏掉。
会传染:`isinstance` 校验改到 `mkdir` 前面之后,后缀校验**还是**留在原地,
因为它藏在分支里,看起来"已经检查过了"。
- 以后怎么防:函数体分三段写——**① 所有校验 → ② 所有副作用(建目录 / 写文件 /
联网 / 改全局状态)→ ③ 主逻辑**。校验用守卫子句集中在开头,不要塞进分支里;
凡是 `raise` 的行,位置必须在校验段内,不能出现在副作用段之后。
写完自问一句:"这个参数不合法时,磁盘 / 网络 / 全局状态会不会已经动过了?"
- 首次:2026-10-01|复发:0
### 6. 把 lint 的"没报错"当成"没问题"
- 现象:`__all__` 与下一个 `def` 之间只留了 1 个空行(PEP 8 要求 2 个),
`ruff check src tests` 照样打印 "All checks passed!"。
- 为什么会错:版本号看着一样,覆盖的规则却是两回事。ruff 0.16 里 `E302`
(函数间空行)属于 **preview 规则**,不在 `select = ["E"]` 的默认集合里——
加了也会被"because preview is not enabled"忽略掉,安安静静不报。
根本问题是:**lint 只检查你 select 的那部分**,而默认规则集本来就不是 PEP 8 全集。
把"我配的检查全过了"读成"这份代码符合规范",是把自己没开的检查当成了已经通过的检查。
- 以后怎么防:把 lint 结论限定成"我 select 的规则里没问题",别说成"没问题"。
想看全貌就偶尔跑一次 `ruff check --select ALL --preview`(只当参考,别直接开进 CI,
会淹没在噪声里);纯格式问题交给 `ruff format`,它是 formatter,不受 `select` 影响。
- 首次:2026-10-01|复发:0
### 7. 写"守卫子句"时,忘了不是所有入参都长一样
- 现象:`winsorize` 开头先算 `numeric_cols = data.select_dtypes(include=['number']).columns`
挡"空数值列",但 `winsorize` 同时收 `DataFrame` 和 `Series` ——
**`Series` 上没有 `select_dtypes`**,传 Series 直接 `AttributeError`,
而且报错就报在守卫那一行(全函数看起来最"安全"的地方)。
- 为什么会错:写的时候只想"要防住空数值列",顺手把守卫放在函数最上面,
没回头确认**这个函数还收哪些类型**。守卫本身没错,错的是它的**位置**:
它对 `DataFrame` 成立、对 `Series` 根本不合法,所以必须排在
`isinstance(data, pd.Series)` 的类型分派**之后**。
本质是:**先做类型分派,再写只对其中一支成立的逻辑**。
- 以后怎么防:写"开头集中校验"之前,先把入参的**全部类型**列一遍,
逐条问"这句话对每种类型都成立吗";不成立的往后挪,
挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。
- 首次:2026-10-02|复发:0
### 8. 改了一处,忘了同源的另一处
- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列
(`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()`
继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。
- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。
同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。
只把后一道收窄、前一道还是全表,等于没修。
修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。
- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**,
把该收窄的地方一次列全,别改完第一处就收手;
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
- 首次:2026-10-02|复发:0
### 9. 实现写完了,测试还挂着 skip —— 绿灯是假的
- 现象:`transform.py` 四个函数都写完了,`pytest` 一片绿(`64 passed`),
但 `test_normalize_minmax_range` / `test_standardize_has_zero_mean_unit_std` 两条
还挂着 `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 新代码**一条测试都没跑到**。
顺带还漏掉一个真错:`normalize` 少了兄弟函数都有的类型守卫,`normalize(None)` 静默返回 NaN。
- 为什么会错:`skip` 是"还没写"的意思,写完实现后该由**写的人**把它摘掉;
但摘 mark 这个动作不在"写函数"的肌肉记忆里,跑一次 `pytest` 看到全绿就以为收工了。
这是决策 26 埋下的反面:逐条 skip 比模块级 skip 精确,但**数量得自己盯**。
- 以后怎么防:每次写完实现,用 `pytest -q -rs`(或 `--collect-only`)**核对 skip 条数掉没掉**;
条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 ——
新写的测试没进收集列表,就是名字或位置不对。
- 首次:2026-10-02|复发:0
### 10. 把真报错当成"编辑器误报"
- 现象:`transform.py` 改完后编辑器(Pylance)在 `pd.DataFrame(result, columns=x.columns,
index=x.index)` 两行画红线,第一反应是"静态检查又误报了,挂个 `# type: ignore` 就完了"。
实际跑一遍:**pyright CLI 报同样的 2 个错**(Pylance 就是 pyright 的内核),
而且 `pytest` **7 条红**(`normalize` 的三种 method 全走错、`one_hot_encode` 把编码列整列丢掉)。
红线只是**症状**,病在代码里。
- 为什么会错:编辑器把错误显示在**它看得见的那一行**,而那一行的类型确实和来源不符;
但"类型不符"既可能是检查器保守(误报),也可能是**上游真的算错了**。
不验证就下"误报"结论,等于用一个假设替代一次检查。
- 以后怎么防:见到红线先做两件事,再谈 `# type: ignore` ——
① 在**终端**跑同引擎的 CLI(`python -m pyright`),排除编辑器缓存/配置差异;
② 跑 `pytest`,看对应的行为断言红不红。两条都过、确认只是检查器保守推断,
才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行
(更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。
- 首次:2026-10-02|复发:0
### 11. pandas 对不齐就静默填 NaN,不报错
- 现象:`describe` 里为了"行 = 变量、列 = 指标"多做了一次 `.T`,然后
`table.loc["missing"] = df_num.isna().sum()`。跑起来**一声不响**,表里却多出四行
**全 NaN**(`missing` / `missing_ratio` / `skewness` / `kurtosis`)。
`.loc[新行标签] = Series` 是按**列名**对齐的:Series 的索引是变量名、表的列名是
指标名,两边**没有一个交集**,pandas 于是把整行填成 NaN —— 不抛异常。
同一个 `.T` 让 `weights` 分支换成 ndarray 后反而直接报
`ValueError: cannot set a row with mismatched columns` —— 那个报错才是救命的。
- 为什么会错:脑子里想的是"给这一行赋值",pandas 做的是"按标签对齐再赋值"。
对齐失败在 pandas 里是**合法结果**(就是缺失),不是错误 —— 它不会替你判断
"这次对齐失败是不是手滑"。
- 以后怎么防:① 往表里加一行前,先确认 Series 的索引 == 目标轴的标签(列名),
拿不准就 `print(list(series.index))` 对着 `list(table.columns)` 看一眼;
② 新加的行/列**立刻断言不是全 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
### 13. `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸
- 现象:`correlation_matrix` 里的判空写成了 `if numeric_cols == 0:`,本意是"一个数值列都没有就报错"。
实际行为分成三段,**每一段都不一样**:
- **0 列**:`array([], dtype=bool)` → `bool(...)` 是 `False` → **静默不报错**,带着空表继续往下跑;
- **1 列**:`array([False])` → 单元素数组能转 `bool` → **碰巧正常**;
- **≥2 列**:`array([False, False, ...])` → `ValueError: The truth value of an array
with more than one element is ambiguous` → **直接崩**。
而"≥2 个数值列"恰恰是最常见的情况,所以这一行等于让整个函数不可用。
- 为什么会错:`Index` 重载了 `==`,做的是**逐元素比较**,返回的是**数组**不是 `bool`。
脑子里比的是"这个序列是不是空的",手上写的是"这个序列的每个元素是不是 0"。
更阴的是 1 列时它能跑通 —— 拿单列试一下就会以为这行没问题。
- 没被抓住的原因:对应的 L0 用例当时还挂着 `skip`(见第 9 条),所以实现零覆盖。
- 以后怎么防:① 判"空"一律 `len(x) == 0`(或 `.empty`),绝不用 `== 0`;
② 判断容器里有没有东西,用 `if not len(...)` 而**不是** `if x:` ——
`if x:` 对 `DataFrame` / `Index` / 多元素 `ndarray` 会抛同一个歧义错误;
③ 新写的分支,**至少拿 0 / 1 / 多三种形状各跑一遍**,单列能过不代表多列能过。
- 首次:2026-10-03|复发:0
### 14. 把"能看的表"和"能算的表"塞进同一个返回值
- 现象:`correlation_matrix` 返回的是一张**带显著性星标的字符串表**(`1.000***` 这种),
直接贴论文很爽。但同一个函数在 docstring 里承诺的是"值 = 相关系数",
L0 用例也断言 `np.allclose(np.diag(...), 1.0)` —— 字符串表两样都做不到:
拿到结果的人**没法**挑高相关对、喂给聚类 / PCA、画热力图。
于是这条用例只能先挂 `xfail`,实现和契约顶在那里。
- 为什么会错:写的时候想的是"我最后要看到什么"(论文里的那张表),
没想"调用方还要拿它**算什么**"。展示需求是**输出端**的事,不该侵占返回值。
一旦返回字符串,数值信息就**不可逆地丢了**(你可以随时把数字格式化成字符串,
但没法把 `"0.570***"` 还原成 `0.570` 和小数点后丢失的精度)。
- 以后怎么防:① 一个函数只返回**一种东西** —— 要么是数据,要么是给人看的展示;
② 判据是"这个返回值还能不能参与下一步计算":不能,就该拆成两个函数;
③ 拆的时候让**计算型**当主函数、**展示型**去调它(`correlation_table` 调
`correlation_matrix` + `correlation_pvalues`),而不是反过来。
- 首次:2026-10-03|复发:0