Files
MCM-ICM_Toolkit/docs/dev/personal-pitfalls.md
T

520 lines
42 KiB
Markdown
Raw Normal View History

# 个人易错点(只追加)
> **本文件的规矩**
> 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 |
| 7 | 写"守卫子句"时,忘了不是所有入参都长一样 | 2026-10-02 |
| 8 | 改了一处,忘了同源的另一处 | 2026-10-02 |
| 9 | 实现写完了,测试还挂着 skip —— 绿灯是假的 | 2026-10-02 |
| 10 | 把真报错当成"编辑器误报" | 2026-10-02 |
| 11 | pandas 对不齐就静默填 NaN,不报错 | 2026-10-03 |
| 12 | 三个并列的 `if`,后面的静默覆盖前面的 | 2026-10-03 |
| 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 |
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 |
| 17 | `hasattr(f, "__name__")` 挡不住 lambda,`else` 分支是死代码 | 2026-10-03 |
| 18 | 入口校验"想起什么写什么",没照 §4.7 清单逐条过 | 2026-10-04 |
| 19 | 只想着主路径那种值,忘了布尔 / NaN / 长度不对 | 2026-10-04 |
| 20 | "不简单"的信号:同一个信息要在调用方写两遍 | 2026-10-04 |
| 21 | 公式里的 `^` 不是乘方,而且被 `except` 吞成 success=False | 2026-10-05 |
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
| 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 |
| 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 |
| 26 | `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 | 2026-10-05 |
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
---
### 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-04):`monte_carlo` 的 `n` 只写了 `isinstance(n, int)` —— `np.int64(1000)` 被 `ValueError` **误拒**,
而 `n=True` 反而放行(`bool` 是 `int` 子类,判断顺序又反了)。
同一张 §4.7 表的第一、三行,症状从"静默兜底"变成"误拒 + 误收":**规则没换,只是没去查**(见第 18 条)。
- 首次:2026-10-01|复发:0 → **1(2026-10-04)**
### 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-05):`monte_carlo_probability` 写 `event: Callable[..., bool]`,docstring 却说"返回长度为 `n` 的**布尔数组**"
—— 注解只描述"返回一个 bool"。pyright 实测:这样写会**拒绝**任何注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),
而运行期它一直都收得下。同一个坑从"收什么"换到了"返回什么":**注解是契约,不是备注**。
- 首次:2026-10-01|复发:1 → **2(2026-10-05)**
### 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-03):`bootstrap_ci` 写完了,`tests/test_stats.py` 里等它的 3 条仍然挂着 skip,
全量照样 134 passed / 37 skipped 全绿 —— 连 `_NOT_IMPLEMENTED` 这个常量名都没提示你。
- 首次:2026-10-02|复发:0 → **1(2026-10-03)**
### 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
### 15. 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 的参数写反了
- 现象:`confidence_interval` 的返回写成了
`return (float(min(0.0, lower)), float(max(upper, 1.0)))`,本意是"比例区间不能越界"。
三个毛病叠在一起:
① **参数写反**:要夹下界应该写 `max(0, lower)`,写成了 `min(0.0, lower)` —— `0.0` 比任何正的下界都小,
`min` 取到的永远是它,于是**下界恒为 0**;上界那条 `max(upper, 1.0)` 同样是反的,
只是它在 `upper > 1` 时凑巧看不出问题。
② **无条件夹**:三种 method(含 `normal` / `t` 这两种**均值**区间)全被夹。
均值区间的量纲根本不在 `[0, 1]` 里:实测
`confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 **0.0**,
而正确结果是 `171.0 ± 2.1`。这种错**不抛异常**,只有把数字打印出来才会发现"身高的下界是 0"。
③ **夹子会吞掉报错信号**:`confidence=1.5` 时 `z` 是 `nan`,`min(0.0, nan)` 返回 `0.0`
(`min` 在遇到 `nan` 之前先锁定了第一个参数),于是本该报错的入参被盖成 `(0.0, nan)`。
另外 `confidence=0` 也一路放行(定义域应是 `(0, 1)` **开区间**)。
- 为什么会错:脑子里记的是"clamp(x, lo, hi)"这个整体,手上却从两个方向各写了一个 `min` / `max`;
更要命的是**试数据选了 `[1, 2, 3, 4, 5]`** —— 它恰好落在 `[0, 1]` 附近,夹不夹都看不出来。
换一个量纲明显不在 `[0, 1]` 的例子(身高 170、GDP 十亿)跑第一遍,这个 bug 一秒现形。
- 以后怎么防:① 夹子只写一个方向:`np.clip(x, lo, hi)`,或 `max(lo, min(hi, x))`;
不要手写两个 `min` / `max` —— 它们看起来对称,其实一个管下界一个管上界,最容易写反。
② **写"区间"函数先做量纲检查**:拿一个边界外的数值当输入,结果被夹到 0 / 1 就是 bug。
③ 边界参数一律按开区间校验(`0 < confidence < 1`),并用 `np.isnan` 兜住参数异常产生的 nan。
④ 记住 `min` / `max` **不传播 nan、结果还取决于参数顺序**(`min(0.0, nan)` → `0.0`、
`min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。
- 首次:2026-10-03|复发:0
### 16. 同一段入口转换抄了 N 遍,还当它是"本项目的写法"
- 现象:`normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
六个函数各自抄了同一段 5~8 行:白名单 `isinstance` → `is_df` / `is_series` → 抓 `columns` / `index`
→ `np.asarray`。这类判断一度散在 **5 个文件 31 处**。抄到第三遍时已经不觉得是重复了,
反而觉得"这就是本项目的入参写法"。
- 为什么会错:重复代码每次只多 3~5 行,看着不痛;但每多一处就多一次**口径分叉**的机会 ——
而且真的分叉了:`normalize` 拿整表 `np.asarray`(遇文本列直接抛)、`zscore` 用 `select_dtypes`
只取数值列、`confidence_interval` 还多一个 `.ravel()`。同一件事三种口径,出 bug 时你还得先判断
"这是哪个副本的行为"。
- 以后怎么防:① 同一段入参处理**抄到第 2 遍**就该抽,别等第 3 遍;
② 抽之前先问一句"输出的形状是不是和输入一样"——**入口能统一、出口不能一刀切**
(`describe` / `correlation_*` 的输出天生不同形,硬套还原会重演第 14 条);
③ 抽出来的函数只干**一件事**:把容器转成数组、并告诉调用方原来是什么。
选列 / 摊平 / 删缺失这些是**语义**,必须留在各函数里(否则重演第 15 条与 `paired=True` 的 NaN 错位)。
- 首次:2026-10-03|复发:0
### 17. `hasattr(f, "__name__")` 判断"是不是具名函数"—— lambda 也有 `__name__`
- 现象:`bootstrap_ci` 想给 `metrics["statistic_name"]` 一个名字,写成了
`statistic.__name__ if hasattr(statistic, '__name__') else "custom"`。
本意是"认得的内置统计量给名字、自定义的标 custom",实际传 lambda 时得到的是
**字符串 `"<lambda>"`** —— 因为 lambda 有 `__name__`,只是值恒为 `"<lambda>"`。
也就是说 `else` 那支**几乎永远走不到**,是死代码。
- 为什么会错:`__name__` 在脑子里等于"函数的名字",于是 `hasattr` 就等于"这是个函数"。
实际上 `__name__` 是**属性存在性**,不是"名字有意义":lambda、`functools.partial` 包过的函数、
`numpy` 的 ufunc(`np.mean.__name__` 是 `"mean"`,能过)各有各的情况,
真正拿不到的只有**没有 `__name__` 的可调用对象**(比如带 `__call__` 的类实例)。
同类陷阱还有 `hasattr(x, "__len__")` 判容器(`str` 也有)、`hasattr(x, "__iter__")` 判列表。
- 以后怎么防:① 判"是不是我想要的那类函数",用**显式白名单**(`if statistic in [np.mean, np.median, ...]`),
别用属性存在性推断语义;② 真要取名字就写成
`name = getattr(f, "__name__", None)` 再判断 `name in (None, "<lambda>")`,
把"没有名字"和"名字是占位符"两种情况都覆盖;③ 每个 `else`/兜底分支都问一句
"**这条到底测得到吗**"——走不到的分支就是 bug 伪装成的防御性代码。
- 首次:2026-10-03|复发:0
### 18. 入口校验"想起什么写什么",没有照着清单逐条过
- 现象:`monte_carlo` 的入口只写了"n 是整数、n 为正",没写"那 `n=True` / `n=np.int64` / `n=10.0` 各走哪条"。
结果 `np.int64(1000)` 被误拒、`n=True` 被放行、浮点 `n` 报的是 `ValueError` 而不是 `TypeError`。
§4.7 那张判断顺序表早就写死了,写代码时一次都没翻。
- 为什么会错:校验是"防御性代码",写的时候满脑子是主路径("次数当然是整数"),
**不会自然想到 `True`、`np.int64`、`None`、0 这些"合法类型里的例外值"**。
清单存在的意义正是替代这种直觉——但清单要主动去查才有用;不查就等于没有。
同类:第 1 条(只写 `int`)、第 7 条(守卫子句的位置)、`random_state=None` 绕开 `get_rng`(决策 46)。
- 以后怎么防:每个公开函数写完,把 §4.7 的表当 checklist **逐行过一遍**
(int + np.integer / bool 排在 int 前 / float + np.floating / 数组先 `np.asarray`),
再用三个探针各打一枪:`f(x=np.int64(1))`、`f(x=True)`、`f(x=1.0)`。
**这一步是本人的活**(决策 49):要不要放宽、报什么错、算不算合法,都是取舍,不是机械活。
- 复发(2026-10-05):`random_walk` 的 `n_steps = 2.5` / `n_steps = True` 又都走 `ValueError("必须是正整数")` ——
同模块的 `monte_carlo` 早就是"类型不对 `TypeError`、取值不对 `ValueError`"。表就在那儿,还是没翻。
- 首次:2026-10-04|复发:**1(2026-10-05)**
### 19. 只想着主路径上的那种值,忘了同一位置还会出现别的类型 / 值
- 现象:`monte_carlo` 的 `simulator` 返回**布尔**数组("事件是否发生",MC 最常见的用法)时,
`np.asarray(...)` 原样保留 bool,`np.std` 在 numpy 2 上直接抛
`numpy boolean subtract ... is not supported`,整个函数判失败;字符串结果则给出
"ufunc isnan not supported"这种看不懂的话。同一轮还漏了:结果里含一个 NaN 时 8 个指标全 NaN 却报 `success=True`。
- 为什么会错:写的时候脑子里只有"年收益 = 浮点数"这一种结果,
"0/1 事件"、"NaN"、"长度对不上"都不在主路径上。而**这个函数恰恰是这几类输入的高发区**
(事件概率、模型发散、批量大小写错),所以它们不是边角料,是常规用法。
- 以后怎么防:写完一个"吃调用方数组"的函数,除了主路径,按五类各跑一次:
**布尔 / 整数 / 含 NaN / 全相同(常量)/ 长度不对**。
类型归一化放在**入口一处**做(`np.asarray(x, dtype=float)`),后面所有指标就都安全了——
这正是 §4.7 表最后一行与 §4.8 存在的理由。
- 首次:2026-10-04|复发:0
### 20. "不简单"的最早信号:同一个信息要在调用方写两遍
- 现象:第一版 `monte_carlo` 的契约是 `sampler(rng, **kwargs)`,但采样器**拿不到 n**,
于是调用方必须写 `n=1000, size=1000` —— 同一个数写两遍,还要靠 `len(data) != n` 事后兜底。
改成实现自己传 `sampler(rng, size=n, **kwargs)` 之后,调用方只写 `n=1000`。
同一处的后遗症:`**kwargs` 里再传 `size` 会撞成 `got multiple values for keyword argument`。
- 为什么会错:设计签名时只想着"参数全靠 `**kwargs` 透传"这一条好处(灵活),
没数"调用方为了让它跑起来还得额外写什么"。
**重复的那一个数是症状,不是问题**:真正的问题是"批量大小"这个信息在接口里**没有归属**。
- 以后怎么防:写完签名,立刻写出**最小调用示例**,然后数两件事:
① 有没有哪个信息写了两遍;② 有没有哪一步"只有我知道"。有 → 把它吸收进实现,而不是写进文档。
简单 ≠ 参数少,而是**调用方不用重复表达**。
- 首次:2026-10-04|复发:0
### 21. 把数学公式抄进代码时,`^` 当成乘方 —— 还被 `except` 吞成 `success=False`
- 现象:`monte_carlo_probability` 的 Wilson 区间里写 `z_score^2 / n`。Python 的 `^` 是**按位异或**,
对 `np.float64` 直接抛 `TypeError: ufunc 'bitwise_xor' not supported`;因为整段计算在
`try/except Exception` 里,对外只表现为 `success=False` + 一句 message ——
最后是 6 条测试全红(`KeyError: 'ci_high'`)才把它挖出来。
- 为什么会错:纸上的 `z²`、`x²` 抄进代码时手会自动敲 `^`(数学记号、Excel、LaTeX 都是这个符号),
而 Python 的乘方是 `**`。更险的是 `^` 在**整数**上是合法异或:`3^2` 得到 1、不报错,只算错。
第二层原因是 `try/except Exception` 把"代码写错"和"数据不合法"混成一个出口,
错误现场(哪一行、哪个异常)被压成一句字符串,静态检查也看不见(pyright 当时 0 error)。
- 以后怎么防:① 公式里的幂一律写 `**`(或 `np.power`),见到 `^` 就当 bug 处理;
② 新函数写完先**手动跑一次正常输入**,`success` 必须是 `True` —— 这条一跑就现形;
③ 给算法配一条**独立实现的对照测试**(Wilson 对 `statsmodels.proportion_confint(method="wilson")`),
这类"公式抄错"会被直接钉死;④ 记住 `except Exception` 越宽,错误越难看见。
- 首次:2026-10-05|复发:0
### 22. 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功"
- 现象:`monte_carlo_integral` 里内层 `monte_carlo` 已经返回 `success=False`(`func` 返回了标量),
但包装层接着无条件执行 `result.message = "蒙特卡洛多重积分成功:…"`。对外就是 `success=False` + 一句"成功"文案。
- 为什么会错:把 `result` 当成"自己的返回值"直接美化,忘了它是**可能失败**的通道。
`success` 和 `message` 是同一份状态的两个视图,分开写就一定有机会不一致 —— 与第 12 条(后面的 `if` 静默覆盖前面的)同源。
- 以后怎么防:包装函数里只改"成功才会有意义"的字段(`name` / `params` / `metrics`),改 `message` 一律包在
`if result.success:` 里;或者只**追加**、不覆盖。写完先跑一次**注定失败**的输入,看对外那两个字段是否自洽。
- 首次:2026-10-05|复发:0
### 23. 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住
- 现象:多重积分里体积写成 `wights = lows - highs`(名字打错 + 减反)。奇数维(1D、3D)估计值全为负、
而 **2D 看起来完全正常**(`∫∫ x·y` 得 +0.2499,正确值 0.25)—— 两个负号相乘抵消,把错误藏住了。
- 为什么会错:① "上界减下界"被顺手写成了"参数顺序相减",而变量名 `wights` 又不像 `lows` / `highs` 那样自带方向提示;
② 验算时只挑了一个二维例子(乘法的对称性恰好掩盖了符号),**维度是奇是偶没进脑子**。
同源教训见第 19 条(只想着主路径那种值)。
- 以后怎么防:长度/宽度/体积一律写 `high - low`,并在心里念一遍"大的减小的";
验算**至少各挑一个奇数维和一个偶数维**(1D + 3D 都能一秒现形);
拿到 `mean` 之后先跟解析值比**符号**,再看数值大小。
- 首次:2026-10-05|复发:0
### 24. 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数
- 现象:`random_walk` 的默认步长采样器写 `rng.integers(0, 2, size=size, dtype=float)`。numpy 的 `Generator.integers`
**不收浮点 dtype**(`TypeError: Unsupported dtype dtype('float64') for integers`),于是 `random_walk(100, 5)`
这种最普通的调用 100% 返回 `success=False`、`data=None`;连 docstring 里那条 `>>> r.data.shape (5, 101)` 都跑不通。
- 为什么会错:写的时候验的是**自定义采样器**那条分支,默认分支(`step_sampler is None`)一次都没执行过;
更巧的是 `try/except Exception` 把它压成了一句 message,看起来像"输入不合法"而不是"代码写错了"。
与第 21 条同源:**错误现场被 `except` 藏起来**,静态检查(pyright)也看不见。
- 以后怎么防:新函数写完,**第一个跑的就是"什么参数都不填的默认调用"**,`success` 必须是 `True`;
再把 docstring 的 Examples 真执行一遍(它们是最便宜的一组冒烟测试)。
凡是用到"库函数 + 一串可选参数"的地方,别凭记忆写,跑一次看它收不收。
- 首次:2026-10-05|复发:0
### 25. 两个维度恰好相等时,转置的形状检查会静默通过
- 现象:`random_walk` 把 `size=(n_paths, n_steps)` 交给 `step_sampler`,但没校验它返回的形状。
实测一个返回**转置**形状 `(n_steps, n_paths)` 的采样器:`n_paths=6, n_steps=4` 时报错(好事),
而 `n_paths=n_steps=5` 时 `success=True`、`data` 形状 `(5, 6)` 也"对"—— 里面的路径却是转置过来的,**静默错**。
- 为什么会错:形状检查依赖"两个维度不相等"这个前提才生效;`cumsum(axis=1)` + 赋值广播在方阵上完全合法。
验算时只挑了一组 `n_paths == n_steps` 的参数,正好落在唯一发现不了的那个点上。
与第 23 条同一个思路的不同侧面:**对称/相等会把错误藏起来**。
- 以后怎么防:验算参数**把两个维度取成不同值**(`n_paths=3, n_steps=100` 这种),别用正方形;
对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。
- 首次:2026-10-05|复发:0
### 26. `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸
- 现象:`random_walk` 把 `call_params = {...}` 写在 `try:` 里面,但 `except` 分支要 `params=call_params`。
于是**每一个失败路径**都变成 `UnboundLocalError: cannot access local variable 'call_params' where it is not associated with a value`
—— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)**5 个全中**,
本该返回 `success=False` 的 `Result`,变成了把异常直接抛给调用方。
- 为什么会错:写的时候脑子里只过"成功路径",`except` 是照上一版的结构留下来的;
**异常分支只有真抛异常才会执行**,正常跑一遍、ruff、pyright 全都看不见(当时两个都 0 error)。
与第 24 条(默认路径没跑过)是同一类:**分支覆盖靠"我以为",不靠执行**。
还有一层:`params` 里那几个值(`n_steps` / `n_paths` / `start` / `random_state`)在 `try` 之前就已经定了,
它们本来就**不属于**"可能失败的计算",放进去纯属位置错误。
- 以后怎么防:① 写 `except` 时,逐个变量问"它是在 `try` 里才被赋值的吗"——**失败的返回要在 `try` 之前准备好**;
② 每写一个 `try`,必须配一个**注定失败**的用例(`assert r.success is False`),专门喂 `except` 分支;
③ 包装层的失败结果也要带 `params`,出问题才追得回去。
- 首次:2026-10-05|复发:0
### 27. 入口按类型分成两条路,两条路的校验口径互相矛盾
- 现象:`markov_simulate` 的 `initial` 用 `isinstance(initial, int)` 分流。标量那条按**路径数**铺满
(`np.full(n_paths, initial)`),数组那条却按**状态数**校验(`len(initial) != s` 报错),
而真正写进去的是 `paths[:, 0] = current_states`,长度要等于**路径数**。于是"数组 + `n_paths != s`"
这条组合永远跑不通;`n_paths == s` 时又能跑通 —— **同一个入口,两个维度各管一半**。
实测 `initial=[0.5, 0.5], n_paths=5` 直接在 `try` 外抛 `ValueError`(本该 `success=False`)。
- 为什么会错:写校验时是"顺着刚写完的那一行"补的 —— 标量分支当年是为了铺满路径,数组分支是照着"转移矩阵的状态数"
顺手写的,两边各自看着都对。**没有回到参数本身的语义上问一句"它到底是按谁计数"**。
同族:`np.int64(0)` 掉进数组分支(`isinstance` 只写了 `int`,易错点 1)、`True` 被当状态 1 静默放行。
- 以后怎么防:① 一个参数有多条类型分支时,**把每条分支的"计数维度"写在一张纸上**(按路径数?按状态数?按列数?),
对不上就说明口径没定清,**先定语义再写代码**;② 分流用的 `isinstance` 必须把 `np.integer` / `bool` 一起想到(易错点 1);
③ 任何"写进结果数组"的赋值,问一遍"它的长度约束和上面校验用的是同一个下标维度吗"。
- 首次:2026-10-05|复发:0
### 28. 数值阈值参数的两个漏网:`bool` 和 NaN
- 现象:`markov_steady_state` 的 `tol` 只写了 `if not isinstance(tol, (int, float)) or tol <= 0`。
`tol=True` → `isinstance(True, int)` 为真、`True > 0` 也为真 → 阈值变成 `1`,
**第 1 次迭代就"收敛"**,返回 `[0.55, 0.45]` 这种假答案,而且 `success=True, converged=True`。
`tol=np.nan` → `nan <= 0` 为假 → 穿过校验 → 白跑满 `max_iter` 次迭代。
同一个函数里 `max_iter` 查了 `bool`、`tol` 没查 —— 说明是**逐条手动补的**,不是照着清单过的。
- 为什么会错:脑子里"阈值 = 一个正数",于是校验只写"是数 & 大于 0";而 `bool` 是 `int` 的子类、
NaN 与任何数比较都是 `False`,这两条都恰好从"是数 & 大于 0"里漏出去。**与易错点 1 同根**,
只是这次漏的是"比较"而不是"类型判断"。
- 同族(同一轮发现的):只校验"转移矩阵行和 = 1.0"就以为矩阵合法 —— `[[1.5, -0.5], [0, 1]]` 行和也是 1.0,
但含负元素,迭代时 `v` 指数发散、最后 `data=[nan, nan]` 却 `success=True`。
**"行和 = 1"不等于"是概率矩阵"**,还要 `arr >= 0`。另外这类溢出会抛 `RuntimeWarning`,
而本项目 `filterwarnings = ["error::RuntimeWarning"]` —— **测试会直接报错**,别指望测试能兜住静默错。
- 以后怎么防:① 校验数值阈值时,脑子里过一遍"三件套":**类型(含 `bool` / `np.integer` 例外)、
有限性(`np.isfinite`,挡 NaN / inf)、取值区间**;② 同一函数里同类参数(`max_iter` / `tol` / `n` / `tol`)
用**同一套模板**写,别一个查 `bool` 一个不查;③ 校验"概率/权重"这类输入时,除了和为 1,还要问一句"有没有可能是负数/NaN"。
- 首次:2026-10-09|复发:0
### 29. 同一个校验里的两把尺子:一个宽容、一个精确
- 现象:`markov_simulate` / `markov_steady_state` 校验转移矩阵时有两条:
`if not np.allclose(arr.sum(axis=1), 1.0)`(**宽容**,默认 `atol=1e-8`)和 `if np.any(arr < 0)`(**精确**)。
实测 `[[1.0, -1e-16], [0.5, 0.5]]`:行和都是 1.0,但左边那行有个 `-1e-16` 的归一化残渣 →
被判"含负元素"直接报错。**这个矩阵在数学上没问题,是校验太严**。
- 为什么会错:两条校验是分两次、照着不同念头写的 —— 行和那条想着"浮点比较不能写 `==`",
非负那条想着"概率怎么能为负"。各自都对,**放在一起就成了一严一宽**。建模时矩阵常常是
`df / df.sum()` 归一化出来的,这类 `-1e-17` 残渣非常常见,比赛现场会直接卡住。
- 同族(同一轮里一起犯的):① 边界比较与文档口径不一致 —— `mm1_metrics` 写 `service_rate < arrival_rate`,
而 docstring 与测试都写 `λ >= μ` 不稳定,于是 `λ == μ` 掉进正常计算分支、`rho = 1` →
`ZeroDivisionError`(结果碰巧对,message 却是"计算失败:ZeroDivisionError");
② 一批报错文案是复制粘贴的,**参数名忘了改** —— 同一轮里 `max_iter` 的文案写 "tol"、
`service_rate` 的两条文案也都写 "tol"。两条都是"看着像对的、跑起来也行、但口径/文案是错的"。
- 以后怎么防:① 同一个量的多条校验,**先把容差定下来再写**(要么都用 `np.allclose` / 小 `eps`,要么都精确),
写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**,
特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。
- 首次:2026-10-09|复发:0
### 30. 改了口径(返回值 / 键名 / 分支),下游三处会同时过期
- 现象:`markov_simulate` 这一轮把 `metrics` 从 `std_terminal` 换成 `terminal_distribution`、
`mm1_metrics` 的不稳定分支从 `metrics={}` 变成 `metrics={"rho"}`、`arrival_rate=0` 从"报错"变成"合法"。
代码本身没问题,但**同一轮的测试 5 条全红**(都在断言旧键名 / 旧分支),docstring 的 `Returns` 也还在写 `std_terminal`。
- 为什么会错:改的时候注意力全在"新行为对不对",**看不见的契约**(测试断言、docstring、STATUS 的口径表)没人提醒你。
这三处过期不会报错,只会"文档说 A、代码做 B",过几天自己都分不清哪个是准的。
- 以后怎么防:① 动返回值 / 键名 / 分支前,先 `rg` 一下这个名字在全仓的出现位置(`rg "std_terminal" `),
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
- 首次:2026-10-10|复发:0