Files
MCM-ICM_Toolkit/docs/dev/personal-pitfalls.md
T
guzhujushi f95fbd2726 fix(stats): t_test/confidence_interval 收口 —— 307/308 是存根误报,CI 的夹取才是真 bug(L1 提前 2 个)
## 用户问的「t_test 307 / 308 两行报错」
- 那两行是 `float(res.statistic)` / `float(res.pvalue)`,报错来自 scipy 的 `TtestResult`:
  它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼出来的类,存根里没有这两个字段。
- 探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常 —— **不是真 bug**。
- 按决策 40 的路子补 `_statistic(result: Any) -> float`(与 `_pvalue` 成对),一行 `# type: ignore` 都没挂。
  详情见 `docs/04-environment.md` 坑 10(已扩写为 pvalue / statistic 两条)。

## 顺手查出来的真 bug:`confidence_interval`
- 原码 `return (float(min(0.0, lower)), float(max(upper, 1.0)))`:
  ① `min` / `max` 的**参数写反**,下界被钉死成 `0.0`;
  ② **无条件**把三种 method 的区间都夹到 `[0, 1]`。
- 实测 `confidence_interval(rng.normal(170, 8, 50), method="t")` 下界是 `0.0`(正确 `171.0 ± 2.1`);
  `confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
- 现在:**只有 `proportion` 夹**(决策 42),`normal` / `t` 返回 `mean ± z|t·se` 不夹;
  `method` 非法抛 `ValueError`(§4.7)、`confidence` 走 `(0, 1)` 开区间、样本量 < 2 抛 `ValueError`。
- 入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
  docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**。

## t_test
- docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7);
  `_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);
  `alpha` 开区间校验;`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
- **4 个口径未代选**(决策 39),已记进 `STATUS.md`「下一步」第 6 条待用户拍板:
  单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、`DataFrame` 摊平多列。

## 测试与文档
- `tests/test_stats.py` +10 条(36 → 46),含把原先挂 skip 的
  `test_confidence_interval_matches_scipy` 转正;对照一律用解析式(z 公式 / Welch df / t 公式)。
- 全量:**125 passed / 37 skipped / 0 xfail**,ruff 0,pyright 0。
- 个人易错点新增第 15 条,并把**停在 6 号**的索引表补齐到 15(只补索引,正文未动)。
- `docs/04-environment.md` 顺手修掉残留的 `[FENCE]` 代码围栏占位符与一处重复的 `---`。
- `STATUS.md`:L0 记 9/12;校正测试数为 162 条;新增决策 42 / 43。
2026-10-03 18:40:41 +08:00

20 KiB
Raw Blame History

个人易错点(只追加)

本文件的规矩

  1. 只追加。 已有条目一个字都不改、不重排、不润色。发现写错了 → 在末尾补一条"更正",不要回头改。
  2. 不通读。 追加前只做两件事:rg -i "<关键词>" docs/dev/personal-pitfalls.md 看有没有记过; 看一眼下面的索引表最后一行拿编号。全文只在复盘时才读。
  3. 一条写三行:现象 / 为什么会错 / 以后怎么防。 写不出"为什么会错"就先别记——那说明还没想清楚。
  4. 记规律,不记事故。"今天又忘了"不新开条目,在已有条目下把 复发 加一。

追加模板

### 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

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

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