Files
MCM-ICM_Toolkit/docs/dev/personal-pitfalls.md
T
guzhujushi 6eab0babde feat(data): transform.py 四个函数落地;补 18 条测试、清 5 条 ruff
实现(用户本人写):
- normalize(minmax / vector / max,三种都按列)+ feature_range 校验 + 常量列不出 nan
- standardize(按列,ddof 真的传下去)+ 类型守卫
- log_transform(offset=None 时自动取 1 - min(x),保证 x + offset >= 1)+ 类型守卫
- one_hot_encode(自动识别对象列 / 指定列 / drop_first)
- data/ 下的 L0 至此结清(L0 7/12)

Codex 的轻度修复(不改接口与语义):
- 清掉 5 条 ruff:RET503 / F541 / RET504 / RET505 / SIM108
- supported_method 与 feature_range 的校验提到 np.asarray 之前(守卫集中开头)
- 修掉说反的报错文案(条件是要求下界 < 上界,原文写"上界必须小于下界")
- one_hot_encode 自动识别时显式加上 str / string:只写 object 在 pandas 3 上是弃用路径,
  每次调用发 Pandas4Warning,未来会变成"一列都选不中"的静默错误(详见坑 9)

测试:
- 新写 18 条(transform 段),其中两条是把原先挂着的 skip 转为实跑 ——
  normalize / standardize 的实现此前一条测试都没跑到(详见个人易错点 9)
- 82 passed / 40 skipped / 0 xfailed;ruff 0;pyright 0

文档:
- STATUS.md:E 节 + 验证表(122 条用例分布)+ 下一步重写(transform 待定的 4 个口径)+ 决策 34
- docs/04-environment.md:新增坑 9(pandas 3 的 str dtype 与 select_dtypes 弃用路径,含实测对照表)
- docs/dev/personal-pitfalls.md:只追加第 9 条(实现写完但测试还挂 skip,绿灯是假的)
- CHANGELOG.md:本轮变更 + 记录 transform 待定的 4 个口径
2026-10-02 20:43:03 +08:00

9.5 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

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