Files
MCM-ICM_Toolkit/docs/dev/personal-pitfalls.md
T
guzhujushi 0955890010 feat(solve): 审查 markov_steady_state:工程侧收口 + 8 条测试
用户实现的 markov_steady_state 经探针实测算法本体正确(3 状态与特征分解逐位一致、
200x200 一致到 1.9e-12 且只用 6 次迭代 / 0.10s),本次只动工程侧,未改任何算法行:

- ruff 5 条 W293 全清;mm1_metrics 前 4 个空行 -> 2;失败文案改成全角冒号
- docstring 补 max_iter / tol 两个参数与 Raises,写清 tol 是相邻差口径
  (实测慢混合链 tol=1e-10 -> 实际误差 4.6e-9,被 1/(1-|lambda2|) 放大约 50 倍)
- Notes 修正"周期链会不收敛"的说法:可约链的实情是平稳分布不唯一,
  迭代法给的是离初值最近的那个(均匀初值对双随机矩阵本身就是不动点)
- tests/test_solve.py:摘掉 1 条 skip + 新增 7 条(该函数共 8 条实跑)
- STATUS.md 新增决策 57(6 条待拍板口径)与第六轮「下一步」;CHANGELOG;易错点 28

校验:pytest 196 passed / 30 skipped(226 收集);ruff 0;pyright 0
2026-10-09 23:20:13 +08:00

490 lines
40 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 |
| 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