2026-10-01 19:38:59 +08:00
|
|
|
|
# 个人易错点(只追加)
|
|
|
|
|
|
|
|
|
|
|
|
> **本文件的规矩**
|
|
|
|
|
|
> 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 |
|
2026-10-01 23:26:47 +08:00
|
|
|
|
| 5 | 参数校验排在副作用(建目录 / 写文件)之后 | 2026-10-01 |
|
|
|
|
|
|
| 6 | 把 lint 的"没报错"当成"没问题" | 2026-10-01 |
|
2026-10-01 19:38:59 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 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 23:11:48 +08:00
|
|
|
|
- 首次:2026-10-01|复发:1
|
2026-10-01 19:38:59 +08:00
|
|
|
|
|
|
|
|
|
|
### 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
|
2026-10-01 23:26:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 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
|
2026-10-02 15:18:15 +08:00
|
|
|
|
|
|
|
|
|
|
### 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
|
2026-10-02 15:41:08 +08:00
|
|
|
|
|
|
|
|
|
|
### 8. 改了一处,忘了同源的另一处
|
|
|
|
|
|
|
|
|
|
|
|
- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列
|
|
|
|
|
|
(`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()`
|
|
|
|
|
|
继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。
|
|
|
|
|
|
- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。
|
|
|
|
|
|
同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。
|
|
|
|
|
|
只把后一道收窄、前一道还是全表,等于没修。
|
|
|
|
|
|
修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。
|
|
|
|
|
|
- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**,
|
|
|
|
|
|
把该收窄的地方一次列全,别改完第一处就收手;
|
|
|
|
|
|
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
|
|
|
|
|
|
- 首次:2026-10-02|复发:0
|
2026-10-02 20:43:03 +08:00
|
|
|
|
|
|
|
|
|
|
### 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
|
2026-10-02 21:48:27 +08:00
|
|
|
|
|
|
|
|
|
|
### 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
|