用户本轮改了 markov_simulate / markov_steady_state 的入口校验,并写完 mm1_metrics,
三处算法本体都正确(mm1 的 5 个公式经探针与闭式解逐位对拍通过,含 W = Wq + 1/mu)。
Codex 只动工程侧:
Fixed
- mm1_metrics 的稳定性分界 "<" 应为 "<="(docstring 与既有测试的口径都是 lambda >= mu 不稳定):
lambda == mu 原先掉进 try、rho == 1.0 触发 ZeroDivisionError,被 except 吞成 success=False。
两个分支的可观测输出完全相同(success=False / metrics={} / data=None),只有 message 不同,
故按决策 31 直接修并加回归测试,未改动任何口径。
- 三处报错文案的参数名抄错:markov_steady_state 的 max_iter <= 0 写成 "tol 必须是正数",
mm1_metrics 的 service_rate 两条报错都写成 "tol";半角逗号统一为全角。
Changed
- mm1_metrics 的 docstring 补 Raises 与 data 说明;call_params = {...} 补空格;
ruff 6 条(W293 x5 / F541 f-string 无占位符 / W292 文件末尾无换行)。
- markov_steady_state 的 try 前 3 个空行 -> 1;"异常: " -> "异常:"。
- tests/test_solve.py:摘掉 2 条 mm1_metrics 的 skip,新增 4 条(lambda == mu 的回归、
非负校验回归、max_iter=3 未收敛判 success=False)。
- STATUS.md 决策 56 / 57 更新办结项 + 新增决策 58(mm1 的 4 条待拍板);CHANGELOG;易错点 29。
校验:pytest 202 passed / 28 skipped(230 收集);ruff 0;pyright 0
41 KiB
41 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 |
| 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不是 pythonint的子类(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 的内核), 而且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
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 个数值列"恰恰是最常见的情况,所以这一行等于让整个函数不可用。
- 0 列:
- 为什么会错:
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