src/stats/descriptive.py 由用户本人实现,这里做审查 + 代修。
describe:
- 真 bug:df_num.describe().T 把表转反(行=变量、列=指标),而 docstring 与
test_describe_reports_expected_keys 定的是"行=指标、列=变量"。转置后
.loc[指标名] = <按变量索引的 Series> 与列名零交集,pandas 不报错,直接把
missing / missing_ratio / skewness / kurtosis 四行整行填成 NaN(静默丢数据);
weights 分支则报 cannot set a row with mismatched columns。去掉 .T 一处修好三件事
- 补 median 行(pandas 只有 50%,测试契约定的是 median)
- 入参放宽到 list / tuple / ndarray(00_quickstart.py 传的就是 ndarray)
- weights 校验补齐:维度 / 长度 / NaN / 负值 / 和为 0(原先和为 0 时裸抛 ZeroDivisionError)
- docstring 按 4.7 补 Parameters / Returns / Raises
zscore: 修 RET505;docstring 补齐;DataFrame 取列处按坑 8 用 cast 收窄
coefficient_of_variation: 均值为 0 改抛 ValueError(取值问题不是类型问题);
空数组提前报错(原先静默 nan + RuntimeWarning);错别字与文案修正
tests: 去掉 test_stats.py 的模块级 pytestmark —— 它把 6 条测试全部跳过(含已实现的
describe),正是决策 26 要禁的写法。改为逐条 skip 未实现的 5 条,并新增 20 条用例。
验证:pytest 102 passed / 39 skipped(原 82/40);ruff All checks passed;pyright 0 errors。
examples/01_clean_and_describe.py 填掉 TODO(L0),实测 exit 0。
docs: STATUS.md L0 8/12 + 决策 35/36 + 三处待拍板口径;CHANGELOG 记录;
personal-pitfalls 追加第 11 条(pandas 对不齐就静默填 NaN)
12 KiB
12 KiB
个人易错点(只追加)
本文件的规矩
- 只追加。 已有条目一个字都不改、不重排、不润色。发现写错了 → 在末尾补一条"更正",不要回头改。
- 不通读。 追加前只做两件事:
rg -i "<关键词>" docs/dev/personal-pitfalls.md看有没有记过; 看一眼下面的索引表最后一行拿编号。全文只在复盘时才读。- 一条写三行:现象 / 为什么会错 / 以后怎么防。 写不出"为什么会错"就先别记——那说明还没想清楚。
- 记规律,不记事故。"今天又忘了"不新开条目,在已有条目下把
复发加一。
追加模板
### 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不是 pythonint的子类(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 的内核), 而且pytest7 条红(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